@shieldaai/agent-map 0.0.0-stage → 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +312 -2
- package/dist/assets.d.ts +5 -0
- package/dist/assets.js +43 -0
- package/dist/boundary.d.ts +7 -0
- package/dist/boundary.js +123 -0
- package/dist/cli-entry.d.ts +2 -0
- package/dist/cli-entry.js +18 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +93 -0
- package/dist/http.d.ts +28 -0
- package/dist/http.js +233 -0
- package/dist/identity-cli.d.ts +9 -0
- package/dist/identity-cli.js +103 -0
- package/dist/identity-http.d.ts +44 -0
- package/dist/identity-http.js +394 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +31 -0
- package/dist/oidc.d.ts +27 -0
- package/dist/oidc.js +311 -0
- package/dist/policy-reader.d.ts +28 -0
- package/dist/policy-reader.js +394 -0
- package/dist/service.d.ts +28 -0
- package/dist/service.js +683 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +466 -0
- package/dist/transport.d.ts +11 -0
- package/dist/transport.js +70 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.js +47 -0
- package/package.json +59 -4
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Driver } from 'neo4j-driver';
|
|
2
|
+
import { type MapReadRequest, type MapStore, type MapView, type ReadScope } from './types.js';
|
|
3
|
+
export interface Neo4jMapStoreOptions {
|
|
4
|
+
readonly database?: string;
|
|
5
|
+
readonly timeoutMs?: number;
|
|
6
|
+
readonly maxSources?: number;
|
|
7
|
+
readonly maxNodes?: number;
|
|
8
|
+
readonly maxEdges?: number;
|
|
9
|
+
/** Host ceiling, also limited by the snapshot's enrolled cloud age limit. */
|
|
10
|
+
readonly policyMaxAgeSeconds?: number;
|
|
11
|
+
readonly clock?: () => number;
|
|
12
|
+
}
|
|
13
|
+
export declare class Neo4jMapStore implements MapStore {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(driver: Driver, options?: Neo4jMapStoreOptions);
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
read(input: ReadScope, request?: MapReadRequest): Promise<MapView>;
|
|
18
|
+
}
|
|
19
|
+
export interface Neo4jMapConnectionOptions extends Neo4jMapStoreOptions {
|
|
20
|
+
readonly uri: string;
|
|
21
|
+
readonly username: string;
|
|
22
|
+
readonly password: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function createNeo4jMapStore(options: Neo4jMapConnectionOptions): Neo4jMapStore;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import neo4j, {} from 'neo4j-driver';
|
|
3
|
+
import { immutableJson } from '@shieldaai/agent-guard';
|
|
4
|
+
import { MAP_LABELS, MAP_RELATIONSHIPS, MapQueryError, mapIdentifier, validateReadScope } from './types.js';
|
|
5
|
+
import { POLICY_COLUMNS, POLICY_QUERY, policyEvidence, policyParameters, policyPlan, safePolicyRow } from './policy-reader.js';
|
|
6
|
+
const CLOUD_FIELDS = ['cloud_status', 'cloud_reason', 'cloud_observed_at', 'cloud_assessment',
|
|
7
|
+
'cloud_provider_label', 'cloud_provider_id', 'cloud_match_kind', 'cloud_node_updated',
|
|
8
|
+
'cloud_ownership_updated', 'cloud_account_updated'];
|
|
9
|
+
const HOST_FIELDS = ['evidence_kind', 'assessment', 'manifest_observed_at'];
|
|
10
|
+
const FIELDS = Object.freeze({
|
|
11
|
+
GuardAgent: ['agent_id', 'name', 'owner_id', 'team_id', ...HOST_FIELDS],
|
|
12
|
+
GuardDeployment: ['deployment_id', 'environment', ...HOST_FIELDS],
|
|
13
|
+
GuardTask: ['task_id', 'principal_id', 'revoked', 'sensitive', 'allowed_recipients', 'allow_control_plane',
|
|
14
|
+
'max_calls', 'max_concurrent', 'max_cost', 'deadline', 'calls', 'concurrent', 'spent', 'reserved'],
|
|
15
|
+
GuardPrincipal: ['principal_id'],
|
|
16
|
+
GuardGrant: ['task_id', 'tool', 'resources', 'evidence_kind', 'assessment'],
|
|
17
|
+
GuardTool: ['name', 'fingerprint', 'revision', 'registered', 'approved', 'known_metadata',
|
|
18
|
+
'requires_approval', 'control_plane', 'max_cost'],
|
|
19
|
+
GuardResource: ['resource_id', 'tool', 'selector_kind'],
|
|
20
|
+
GuardResourceBinding: ['binding_id', 'tool', 'resource_ref', 'actions', 'scope', ...HOST_FIELDS],
|
|
21
|
+
GuardExecutionBinding: ['binding_id', 'tool', 'connection_id', 'identity_selection', ...HOST_FIELDS],
|
|
22
|
+
GuardExternalResource: ['provider', 'partition', 'account_id', 'region', 'arn', 'kind', ...HOST_FIELDS, ...CLOUD_FIELDS],
|
|
23
|
+
GuardExternalIdentity: ['provider', 'partition', 'account_id', 'region', 'arn', ...HOST_FIELDS, ...CLOUD_FIELDS],
|
|
24
|
+
GuardMcpConnection: ['connection_id', 'capture_status', 'unavailable_reason', 'capture_observed_at',
|
|
25
|
+
'server_id', 'transport_ref', 'state', 'metadata_state', 'observed_at', 'evidence_kind', 'assessment'],
|
|
26
|
+
GuardMcpOperation: ['connection_id', 'name', 'kind', 'fingerprint', 'evidence_kind', 'assessment'],
|
|
27
|
+
});
|
|
28
|
+
const SOURCES = `MATCH (s:GuardSource)
|
|
29
|
+
WHERE s.company_id=$companyId AND s.tenant_id IN $tenantIds AND ($sourceId IS NULL OR s.id=$sourceId)
|
|
30
|
+
AND coalesce(s.lifecycle_status,'active')='active'
|
|
31
|
+
MATCH (p:GuardSnapshot {id:s.current_snapshot_id})
|
|
32
|
+
WHERE p.source_id=s.id AND p.status='published' AND
|
|
33
|
+
(($allTeams AND $agentId IS NULL) OR EXISTS {
|
|
34
|
+
MATCH (a:GuardAgent)-[:G_IN_SNAPSHOT]->(p)
|
|
35
|
+
WHERE a.source_id=s.id AND a.projection_id=p.id AND a.tenant_id=s.tenant_id
|
|
36
|
+
AND ($allTeams OR a.team_id IN $teamIds)
|
|
37
|
+
AND ($agentId IS NULL OR a.agent_id=$agentId OR a.entity_id=$agentId)
|
|
38
|
+
})
|
|
39
|
+
RETURN s.id AS id,s.tenant_id AS tenantId,s.native_source_id AS nativeSourceId,s.profile AS profile,
|
|
40
|
+
p.id AS currentId,p.snapshot_id AS currentSnapshotId,p.generated_at AS currentGeneratedAt,
|
|
41
|
+
substring(p.coverage_json,0,4097) AS currentCoverage,
|
|
42
|
+
s.previous_snapshot_id AS previousId,substring(p.cloud_coverage_json,0,16385) AS policyCoverage
|
|
43
|
+
ORDER BY s.id LIMIT $sourceLimit`;
|
|
44
|
+
const scoped = (variable) => `${variable}.source_id=$sourceId AND ${variable}.projection_id=$projectionId AND ${variable}.tenant_id=$tenantId`;
|
|
45
|
+
// Derive each next set only from previously authorized nodes, with bounds after every set.
|
|
46
|
+
const READ_ORDER = ['GuardAgent', 'GuardDeployment', 'GuardTask', 'GuardPrincipal',
|
|
47
|
+
'GuardGrant', 'GuardExecutionBinding', 'GuardTool', 'GuardResource', 'GuardResourceBinding',
|
|
48
|
+
'GuardExternalResource', 'GuardExternalIdentity', 'GuardMcpConnection', 'GuardMcpOperation'];
|
|
49
|
+
const historicalVisibility = (label, parameter) => `($projectionId=$currentId OR $allTeams OR n.entity_id IN $${parameter} OR NOT EXISTS {
|
|
50
|
+
MATCH (current:${label})-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$currentId})
|
|
51
|
+
WHERE current.entity_id=n.entity_id AND current.source_id=$sourceId
|
|
52
|
+
AND current.projection_id=$currentId AND current.tenant_id=$tenantId
|
|
53
|
+
})`;
|
|
54
|
+
function through(path, label) {
|
|
55
|
+
return `EXISTS { MATCH ${path} WHERE ${scoped('visible')} AND visible.id IN $${label}Ids }`;
|
|
56
|
+
}
|
|
57
|
+
const predicates = {
|
|
58
|
+
GuardAgent: `($allTeams OR n.team_id IN $teamIds) AND ($agentId IS NULL OR n.agent_id=$agentId OR n.entity_id=$agentId)
|
|
59
|
+
AND ${historicalVisibility('GuardAgent', 'currentAgentEntityIds')}`,
|
|
60
|
+
GuardDeployment: through('(n)-[:G_INSTANCE_OF]->(visible:GuardAgent)', 'GuardAgent'),
|
|
61
|
+
GuardTask: `${through('(n)-[:G_ASSIGNED_TO]->(visible:GuardDeployment)', 'GuardDeployment')}
|
|
62
|
+
AND ${historicalVisibility('GuardTask', 'currentTaskEntityIds')}`,
|
|
63
|
+
GuardPrincipal: through('(n)<-[:G_ACTS_AS]-(visible:GuardTask)', 'GuardTask'),
|
|
64
|
+
GuardGrant: through('(n)<-[:G_HAS_GRANT]-(visible:GuardTask)', 'GuardTask'),
|
|
65
|
+
GuardExecutionBinding: through('(n)-[:G_EXECUTES_FOR]->(visible:GuardDeployment)', 'GuardDeployment'),
|
|
66
|
+
GuardTool: `(${through('(n)<-[:G_FOR_TOOL]-(visible:GuardGrant)', 'GuardGrant')} OR
|
|
67
|
+
${through('(n)<-[:G_EXECUTES_TOOL]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding')})`,
|
|
68
|
+
GuardResource: through('(n)<-[:G_TARGETS]-(visible:GuardGrant)', 'GuardGrant'),
|
|
69
|
+
GuardResourceBinding: through('(n)-[:G_BINDS_SELECTOR]->(visible:GuardResource)', 'GuardResource'),
|
|
70
|
+
GuardExternalResource: through('(n)<-[:G_MAPS_TO]-(visible:GuardResourceBinding)', 'GuardResourceBinding'),
|
|
71
|
+
GuardExternalIdentity: through('(n)<-[:G_USES_IDENTITY]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding'),
|
|
72
|
+
GuardMcpConnection: through('(n)<-[:G_VIA_CONNECTION]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding'),
|
|
73
|
+
GuardMcpOperation: `EXISTS {
|
|
74
|
+
MATCH (n)<-[:G_EXPOSES_OPERATION]-(c:GuardMcpConnection)<-[:G_VIA_CONNECTION]-(b:GuardExecutionBinding)
|
|
75
|
+
MATCH (b)-[:G_EXECUTES_TOOL]->(tool:GuardTool)<-[:G_OPERATION_TOOL]-(n)
|
|
76
|
+
WHERE ${['c', 'b', 'tool'].map(scoped).join(' AND ')}
|
|
77
|
+
AND c.id IN $GuardMcpConnectionIds AND b.id IN $GuardExecutionBindingIds AND tool.id IN $GuardToolIds
|
|
78
|
+
}`,
|
|
79
|
+
};
|
|
80
|
+
// Labels/property names below are static source allowlists, never request-supplied strings.
|
|
81
|
+
// Separate bounded templates avoid the planner's combinatorial work for a large OR of paths.
|
|
82
|
+
const NODES = Object.fromEntries(MAP_LABELS.map(label => [label,
|
|
83
|
+
`MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})
|
|
84
|
+
MATCH (n:${label})-[:G_IN_SNAPSHOT]->(p)
|
|
85
|
+
WHERE ${scoped('n')} AND size([label IN labels(n) WHERE label IN $labels])=1 AND
|
|
86
|
+
(($allTeams AND $agentId IS NULL) OR (${predicates[label]}))
|
|
87
|
+
RETURN '${label}' AS label,n { .id,.entity_id,.source_id,.projection_id,${FIELDS[label].map(field => `.${field}`).join(',')} } AS node
|
|
88
|
+
ORDER BY n.id LIMIT $nodeLimit`]));
|
|
89
|
+
const EDGES = `MATCH (n)-[r]->(m)
|
|
90
|
+
WHERE n.id IN $nodeIds AND m.id IN $nodeIds AND type(r) IN $relationships
|
|
91
|
+
AND n.source_id=$sourceId AND m.source_id=$sourceId
|
|
92
|
+
AND n.projection_id=$projectionId AND m.projection_id=$projectionId
|
|
93
|
+
AND n.tenant_id=$tenantId AND m.tenant_id=$tenantId
|
|
94
|
+
RETURN n.id AS source,m.id AS target,type(r) AS kind ORDER BY source,target,kind LIMIT $edgeLimit`;
|
|
95
|
+
const PREVIOUS = `MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})
|
|
96
|
+
RETURN p.id AS id,p.snapshot_id AS snapshotId,p.generated_at AS generatedAt,
|
|
97
|
+
substring(p.coverage_json,0,4097) AS coverage LIMIT 1`;
|
|
98
|
+
function bound(value, fallback, maximum) {
|
|
99
|
+
const number = value === undefined ? fallback : value;
|
|
100
|
+
if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < 1 || number > maximum)
|
|
101
|
+
throw new MapQueryError('MAP_REQUEST_INVALID');
|
|
102
|
+
return number;
|
|
103
|
+
}
|
|
104
|
+
function text(value) { return mapIdentifier(value, 'MAP_UNAVAILABLE'); }
|
|
105
|
+
function converted(value) {
|
|
106
|
+
if (neo4j.isInt(value)) {
|
|
107
|
+
if (!value.inSafeRange())
|
|
108
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
109
|
+
return value.toNumber();
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(value))
|
|
112
|
+
return value.map(converted);
|
|
113
|
+
if (value && typeof value === 'object')
|
|
114
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, converted(child)]));
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
function safeNode(raw, label, sourceId, projectionId) {
|
|
118
|
+
if (!MAP_LABELS.includes(label) || !raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
119
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
120
|
+
const data = raw;
|
|
121
|
+
if (data.source_id !== sourceId || data.projection_id !== projectionId)
|
|
122
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
123
|
+
const properties = {};
|
|
124
|
+
for (const field of FIELDS[label])
|
|
125
|
+
if (Object.hasOwn(data, field))
|
|
126
|
+
properties[field] = converted(data[field]);
|
|
127
|
+
return { id: text(data.id), entityId: text(data.entity_id), sourceId, projectionId, label: label,
|
|
128
|
+
properties: immutableJson(properties, 4, 50_000, 512 * 1024) };
|
|
129
|
+
}
|
|
130
|
+
function observationTime(value) {
|
|
131
|
+
if (typeof value !== 'string' || !/^(?!0000)[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,6})?Z$/.test(value))
|
|
132
|
+
return undefined;
|
|
133
|
+
const time = Date.parse(value);
|
|
134
|
+
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 19) === value.slice(0, 19) ? time : undefined;
|
|
135
|
+
}
|
|
136
|
+
function coreCaptureTimes(raw) {
|
|
137
|
+
// Older or incomplete graph metadata has unknown capture age. Recomposition
|
|
138
|
+
// changes generated_at, so it is never a safe substitute for these timestamps.
|
|
139
|
+
if (raw === null || raw === undefined)
|
|
140
|
+
return {};
|
|
141
|
+
try {
|
|
142
|
+
if (typeof raw !== 'string' || Buffer.byteLength(raw, 'utf8') > 4096)
|
|
143
|
+
throw new Error();
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
146
|
+
throw new Error();
|
|
147
|
+
const result = {};
|
|
148
|
+
for (const component of ['tasks', 'tools']) {
|
|
149
|
+
const item = parsed[component];
|
|
150
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
151
|
+
throw new Error();
|
|
152
|
+
const record = item;
|
|
153
|
+
if (Object.keys(record).sort().join(',') !== 'observedAt,status' || record.status !== 'complete' ||
|
|
154
|
+
observationTime(record.observedAt) === undefined)
|
|
155
|
+
throw new Error();
|
|
156
|
+
result[component] = record.observedAt;
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function coverage(nodes, captures, profile, summary, now) {
|
|
165
|
+
const connections = nodes.filter(node => node.label === 'GuardMcpConnection');
|
|
166
|
+
const observed = connections.filter(node => node.properties.capture_status === 'observed').length;
|
|
167
|
+
const references = nodes.filter(node => node.label === 'GuardExternalResource' || node.label === 'GuardExternalIdentity');
|
|
168
|
+
const captured = references.filter(node => typeof node.properties.cloud_status === 'string');
|
|
169
|
+
const resolved = captured.filter(node => node.properties.cloud_status === 'resolved').length;
|
|
170
|
+
const at = (values) => {
|
|
171
|
+
const times = values.map(value => typeof value === 'number' ? value * 1000 : typeof value === 'string' ? Date.parse(value) : NaN)
|
|
172
|
+
.filter(value => Number.isFinite(value) && value >= 0 && value <= 253_402_300_799_999);
|
|
173
|
+
return times.length ? { observedAt: new Date(Math.min(...times)).toISOString() } : {};
|
|
174
|
+
};
|
|
175
|
+
// Aggregate only visible connections. A new composition cannot renew a cached
|
|
176
|
+
// observation, and one missing/invalid/future timestamp must not disappear
|
|
177
|
+
// behind another fresh connection. Unavailable records have only capture time.
|
|
178
|
+
const mcpTimes = connections.flatMap(node => node.properties.capture_status === 'observed'
|
|
179
|
+
? [node.properties.capture_observed_at, node.properties.observed_at]
|
|
180
|
+
: [node.properties.capture_observed_at]).map(observationTime);
|
|
181
|
+
const mcpCapture = mcpTimes.length && mcpTimes.every(time => time !== undefined && time <= now + 60_000)
|
|
182
|
+
? { observedAt: new Date(Math.min(...mcpTimes)).toISOString() } : {};
|
|
183
|
+
return {
|
|
184
|
+
tasks: summary ? { status: 'not_collected' } : { status: 'complete', ...(captures.tasks ? { observedAt: captures.tasks } : {}) },
|
|
185
|
+
tools: summary ? { status: 'not_collected' } : { status: 'complete', ...(captures.tools ? { observedAt: captures.tools } : {}) },
|
|
186
|
+
manifest: profile === 'core' ? { status: 'not_collected' } : { status: summary ? 'partial' : 'complete', ...at(nodes.map(node => node.properties.manifest_observed_at)) },
|
|
187
|
+
mcp: summary || profile !== 'host-mcp' ? { status: 'not_collected' } :
|
|
188
|
+
{ status: observed === connections.length ? 'complete' : observed ? 'partial' : 'unavailable', ...mcpCapture },
|
|
189
|
+
cloud: !captured.length ? { status: 'not_collected' } :
|
|
190
|
+
{ status: resolved === references.length ? 'complete' : resolved ? 'partial' : 'unavailable', ...at(captured.map(node => node.properties.cloud_observed_at)) },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export class Neo4jMapStore {
|
|
194
|
+
#driver;
|
|
195
|
+
#database;
|
|
196
|
+
#timeout;
|
|
197
|
+
#sources;
|
|
198
|
+
#nodes;
|
|
199
|
+
#edges;
|
|
200
|
+
#policyAge;
|
|
201
|
+
#clock;
|
|
202
|
+
#active = 0;
|
|
203
|
+
constructor(driver, options = {}) {
|
|
204
|
+
this.#driver = driver;
|
|
205
|
+
this.#database = mapIdentifier(options.database ?? 'neo4j');
|
|
206
|
+
this.#timeout = bound(options.timeoutMs, 5000, 5000);
|
|
207
|
+
this.#sources = bound(options.maxSources, 64, 64);
|
|
208
|
+
this.#nodes = bound(options.maxNodes, 2000, 2000);
|
|
209
|
+
this.#edges = bound(options.maxEdges, 5000, 5000);
|
|
210
|
+
this.#policyAge = bound(options.policyMaxAgeSeconds, 7200, 86400);
|
|
211
|
+
if (options.clock !== undefined && typeof options.clock !== 'function')
|
|
212
|
+
throw new MapQueryError('MAP_REQUEST_INVALID');
|
|
213
|
+
this.#clock = options.clock ?? Date.now;
|
|
214
|
+
}
|
|
215
|
+
async close() { await this.#driver.close(); }
|
|
216
|
+
async read(input, request = {}) {
|
|
217
|
+
const scope = validateReadScope(input);
|
|
218
|
+
let selection;
|
|
219
|
+
try {
|
|
220
|
+
const copied = immutableJson(request, 2, 16, 32 * 1024);
|
|
221
|
+
if (!copied || Array.isArray(copied) || typeof copied !== 'object' ||
|
|
222
|
+
Object.keys(copied).some(key => !['sourceId', 'agentId', 'history', 'summary'].includes(key)))
|
|
223
|
+
throw new Error();
|
|
224
|
+
if (copied.sourceId !== undefined)
|
|
225
|
+
mapIdentifier(copied.sourceId);
|
|
226
|
+
if (copied.agentId !== undefined)
|
|
227
|
+
mapIdentifier(copied.agentId);
|
|
228
|
+
if (copied.history !== undefined && typeof copied.history !== 'boolean')
|
|
229
|
+
throw new Error();
|
|
230
|
+
if (copied.summary !== undefined && typeof copied.summary !== 'boolean')
|
|
231
|
+
throw new Error();
|
|
232
|
+
selection = copied;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
throw new MapQueryError('MAP_REQUEST_INVALID');
|
|
236
|
+
}
|
|
237
|
+
if (this.#active >= 8)
|
|
238
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
239
|
+
this.#active++;
|
|
240
|
+
const deadline = Date.now() + this.#timeout;
|
|
241
|
+
let cancel = () => { };
|
|
242
|
+
const check = () => { if (Date.now() >= deadline)
|
|
243
|
+
throw new MapQueryError('MAP_TIMEOUT'); };
|
|
244
|
+
const work = (async () => {
|
|
245
|
+
const session = this.#driver.session({ database: this.#database, defaultAccessMode: neo4j.session.READ, fetchSize: 32 });
|
|
246
|
+
let transaction;
|
|
247
|
+
try {
|
|
248
|
+
transaction = session.beginTransaction({ timeout: this.#timeout });
|
|
249
|
+
const tx = transaction;
|
|
250
|
+
cancel = () => { void tx.rollback().catch(() => { }); };
|
|
251
|
+
const run = async (query, parameters, receive) => {
|
|
252
|
+
check();
|
|
253
|
+
const records = [];
|
|
254
|
+
for await (const record of tx.run(query, parameters)) {
|
|
255
|
+
check();
|
|
256
|
+
if (receive)
|
|
257
|
+
receive(record);
|
|
258
|
+
else
|
|
259
|
+
records.push(record);
|
|
260
|
+
}
|
|
261
|
+
check();
|
|
262
|
+
return records;
|
|
263
|
+
};
|
|
264
|
+
const common = { companyId: scope.companyId, tenantIds: [...scope.tenantIds], teamIds: [...scope.teamIds],
|
|
265
|
+
allTeams: scope.allTeams, sourceId: selection.sourceId ?? null, agentId: selection.agentId ?? null,
|
|
266
|
+
sourceLimit: neo4j.int(this.#sources + 1) };
|
|
267
|
+
const selected = await run(SOURCES, common);
|
|
268
|
+
if (selected.length > this.#sources)
|
|
269
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
270
|
+
let nodeCount = 0;
|
|
271
|
+
let edgeCount = 0;
|
|
272
|
+
let materializedBytes = 0;
|
|
273
|
+
const charge = (value) => {
|
|
274
|
+
materializedBytes += Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
275
|
+
if (materializedBytes > 4 * 1024 * 1024)
|
|
276
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
277
|
+
};
|
|
278
|
+
const now = () => {
|
|
279
|
+
let stamp;
|
|
280
|
+
try {
|
|
281
|
+
stamp = this.#clock();
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
285
|
+
}
|
|
286
|
+
if (!Number.isSafeInteger(stamp) || stamp < 0 || stamp > 253_402_300_799_999)
|
|
287
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
288
|
+
return Math.floor(stamp / 1000);
|
|
289
|
+
};
|
|
290
|
+
let policyPairCount = 0;
|
|
291
|
+
const policyChecks = [];
|
|
292
|
+
const readPolicy = async (plan, params, chargeRows) => {
|
|
293
|
+
if (!plan.probes.length)
|
|
294
|
+
return [];
|
|
295
|
+
const rows = [];
|
|
296
|
+
let readBytes = 0;
|
|
297
|
+
const pairs = policyParameters(plan);
|
|
298
|
+
if (Buffer.byteLength(JSON.stringify(pairs), 'utf8') > 4 * 1024 * 1024)
|
|
299
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
300
|
+
await run(POLICY_QUERY, { ...params, policyPairs: pairs, policyRowLimit: neo4j.int(plan.probes.length * 3 + 1) }, item => {
|
|
301
|
+
if (rows.length >= plan.probes.length * 3)
|
|
302
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
303
|
+
const raw = Object.fromEntries(POLICY_COLUMNS.map(column => [column, converted(item.get(column))]));
|
|
304
|
+
const safe = safePolicyRow(raw);
|
|
305
|
+
readBytes += Buffer.byteLength(JSON.stringify(safe), 'utf8');
|
|
306
|
+
if (readBytes > 4 * 1024 * 1024)
|
|
307
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
308
|
+
if (chargeRows)
|
|
309
|
+
charge(safe);
|
|
310
|
+
rows.push(safe);
|
|
311
|
+
});
|
|
312
|
+
// Sort only the bounded materialized rows. A server ORDER BY would
|
|
313
|
+
// expand malformed relationship fan-out before applying its LIMIT.
|
|
314
|
+
rows.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b), 'en-US'));
|
|
315
|
+
return rows;
|
|
316
|
+
};
|
|
317
|
+
const sources = [];
|
|
318
|
+
for (const record of selected) {
|
|
319
|
+
const sourceId = text(record.get('id'));
|
|
320
|
+
const tenantId = text(record.get('tenantId'));
|
|
321
|
+
const currentId = text(record.get('currentId'));
|
|
322
|
+
const profile = record.get('profile');
|
|
323
|
+
if (!scope.tenantIds.includes(tenantId) || !['core', 'host', 'host-mcp'].includes(profile))
|
|
324
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
325
|
+
const readProjection = async (id, snapshotId, generatedAt, rawCoverage, currentNodes = []) => {
|
|
326
|
+
const captures = coreCaptureTimes(rawCoverage);
|
|
327
|
+
const params = { ...common, sourceId, tenantId, projectionId: id, currentId, labels: [...MAP_LABELS],
|
|
328
|
+
currentAgentEntityIds: currentNodes.filter(node => node.label === 'GuardAgent').map(node => node.entityId),
|
|
329
|
+
currentTaskEntityIds: currentNodes.filter(node => node.label === 'GuardTask').map(node => node.entityId) };
|
|
330
|
+
const nodes = [];
|
|
331
|
+
for (const label of selection.summary ? ['GuardAgent', 'GuardDeployment'] : READ_ORDER) {
|
|
332
|
+
const authorized = Object.fromEntries(MAP_LABELS.map(kind => [`${kind}Ids`, nodes.filter(node => node.label === kind).map(node => node.id)]));
|
|
333
|
+
await run(NODES[label], { ...params, ...authorized, nodeLimit: neo4j.int(this.#nodes - nodeCount + 1) }, item => {
|
|
334
|
+
if (++nodeCount > this.#nodes)
|
|
335
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
336
|
+
if (item.get('label') !== label)
|
|
337
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
338
|
+
const node = safeNode(item.get('node'), label, sourceId, id);
|
|
339
|
+
charge(node);
|
|
340
|
+
nodes.push(node);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const nodeIds = nodes.map(node => node.id);
|
|
344
|
+
if (new Set(nodeIds).size !== nodes.length)
|
|
345
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
346
|
+
const ids = new Set(nodeIds);
|
|
347
|
+
const edges = [];
|
|
348
|
+
await run(EDGES, { ...params, nodeIds, relationships: [...MAP_RELATIONSHIPS],
|
|
349
|
+
edgeLimit: neo4j.int(this.#edges - edgeCount + 1) }, item => {
|
|
350
|
+
if (++edgeCount > this.#edges)
|
|
351
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
352
|
+
const source = text(item.get('source')), target = text(item.get('target')), kind = item.get('kind');
|
|
353
|
+
if (!ids.has(source) || !ids.has(target) || !MAP_RELATIONSHIPS.includes(kind))
|
|
354
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
355
|
+
const edge = { source, target, kind: kind };
|
|
356
|
+
charge(edge);
|
|
357
|
+
edges.push(edge);
|
|
358
|
+
});
|
|
359
|
+
const projection = { id, snapshotId, generatedAt, status: 'published', nodes, edges, coverage: coverage(nodes, captures, profile, selection.summary ?? false, now() * 1000) };
|
|
360
|
+
if (!selection.summary && !selection.history && id === currentId) {
|
|
361
|
+
const plan = policyPlan(scope.companyId, tenantId, nodes, edges, 512 - policyPairCount);
|
|
362
|
+
policyPairCount += plan.records.length;
|
|
363
|
+
let maximumAge = this.#policyAge;
|
|
364
|
+
const enrolled = record.get('policyCoverage');
|
|
365
|
+
if (enrolled !== null && enrolled !== undefined && plan.probes.length) {
|
|
366
|
+
try {
|
|
367
|
+
if (typeof enrolled !== 'string' || enrolled.length > 16_384)
|
|
368
|
+
throw new Error();
|
|
369
|
+
const parsed = JSON.parse(enrolled);
|
|
370
|
+
if (!parsed || typeof parsed !== 'object' || !Number.isSafeInteger(parsed.maxAgeSeconds) ||
|
|
371
|
+
parsed.maxAgeSeconds < 1 || parsed.maxAgeSeconds > 604800)
|
|
372
|
+
throw new Error();
|
|
373
|
+
maximumAge = Math.min(maximumAge, parsed.maxAgeSeconds);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const rows = await readPolicy(plan, params, true);
|
|
380
|
+
const evidence = policyEvidence(plan, rows, now(), maximumAge);
|
|
381
|
+
for (const item of evidence)
|
|
382
|
+
charge(item);
|
|
383
|
+
const enriched = { ...projection, policyEvidence: evidence };
|
|
384
|
+
policyChecks.push({ plan, params, rows, projection: enriched, maximumAge });
|
|
385
|
+
return enriched;
|
|
386
|
+
}
|
|
387
|
+
return projection;
|
|
388
|
+
};
|
|
389
|
+
const current = await readProjection(currentId, text(record.get('currentSnapshotId')), text(record.get('currentGeneratedAt')), record.get('currentCoverage'));
|
|
390
|
+
let previous = null;
|
|
391
|
+
if (selection.history && record.get('previousId')) {
|
|
392
|
+
const previousId = text(record.get('previousId'));
|
|
393
|
+
const previousRecords = await run(PREVIOUS, { sourceId, projectionId: previousId });
|
|
394
|
+
if (previousRecords.length === 1) {
|
|
395
|
+
const item = previousRecords[0];
|
|
396
|
+
previous = await readProjection(previousId, text(item.get('snapshotId')), text(item.get('generatedAt')), item.get('coverage'), current.nodes);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
sources.push({ id: sourceId, tenantId, sourceId: text(record.get('nativeSourceId')), profile, current, previous });
|
|
400
|
+
}
|
|
401
|
+
// Publication may advance while the read transaction is open. Never return mixed generations.
|
|
402
|
+
const after = await run(SOURCES, common);
|
|
403
|
+
const pointers = (items) => JSON.stringify(items.map(record => [record.get('id'), record.get('currentId'), record.get('previousId'), record.get('currentCoverage'), record.get('policyCoverage')]));
|
|
404
|
+
if (pointers(selected) !== pointers(after))
|
|
405
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
406
|
+
for (const checked of policyChecks) {
|
|
407
|
+
const rows = await readPolicy(checked.plan, checked.params, false);
|
|
408
|
+
if (JSON.stringify(rows) !== JSON.stringify(checked.rows))
|
|
409
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
410
|
+
const refreshed = policyEvidence(checked.plan, rows, now(), checked.maximumAge);
|
|
411
|
+
if (JSON.stringify(refreshed) !== JSON.stringify(checked.projection.policyEvidence))
|
|
412
|
+
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
return immutableJson({ sources }, 16, 200_000, 4 * 1024 * 1024);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (error instanceof MapQueryError)
|
|
423
|
+
throw error;
|
|
424
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
425
|
+
throw new MapQueryError(/TimedOut|Timeout/.test(code) ? 'MAP_TIMEOUT' : 'MAP_UNAVAILABLE');
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
if (transaction?.isOpen())
|
|
429
|
+
await transaction.rollback().catch(() => { });
|
|
430
|
+
await session.close().catch(() => { });
|
|
431
|
+
}
|
|
432
|
+
})();
|
|
433
|
+
void work.finally(() => { this.#active--; }).catch(() => { });
|
|
434
|
+
let timer;
|
|
435
|
+
const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { cancel(); reject(new MapQueryError('MAP_TIMEOUT')); }, this.#timeout); });
|
|
436
|
+
try {
|
|
437
|
+
return await Promise.race([work, timeout]);
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
if (timer)
|
|
441
|
+
clearTimeout(timer);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
export function createNeo4jMapStore(options) {
|
|
446
|
+
try {
|
|
447
|
+
const uri = new URL(options.uri);
|
|
448
|
+
const host = uri.hostname.replace(/^\[|\]$/g, '');
|
|
449
|
+
const local = isIP(host) === 4 ? host.startsWith('127.') : host === '::1';
|
|
450
|
+
if (!['bolt:', 'bolt+s:', 'neo4j+s:'].includes(uri.protocol) || !host || uri.username || uri.password ||
|
|
451
|
+
uri.pathname || uri.search || uri.hash || (uri.port && (+uri.port < 1 || +uri.port > 65535)) ||
|
|
452
|
+
(uri.protocol === 'bolt:' && !local))
|
|
453
|
+
throw new Error();
|
|
454
|
+
mapIdentifier(options.username);
|
|
455
|
+
if (typeof options.password !== 'string' || !options.password || options.password.length > 8192)
|
|
456
|
+
throw new Error();
|
|
457
|
+
const driver = neo4j.driver(options.uri, neo4j.auth.basic(options.username, options.password), {
|
|
458
|
+
connectionTimeout: 5000, connectionAcquisitionTimeout: 5000, maxTransactionRetryTime: 0,
|
|
459
|
+
maxConnectionPoolSize: 8,
|
|
460
|
+
});
|
|
461
|
+
return new Neo4jMapStore(driver, options);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
throw new MapQueryError('MAP_REQUEST_INVALID');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
export declare function safeError(error: unknown): string;
|
|
3
|
+
export declare function headers(response: ServerResponse, size: number, mime?: string): void;
|
|
4
|
+
export declare function errorResponse(response: ServerResponse, code: string): void;
|
|
5
|
+
export declare const count: (request: IncomingMessage, name: string) => number;
|
|
6
|
+
export type Bucket = {
|
|
7
|
+
since: number;
|
|
8
|
+
count: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function consume(bucket: Bucket, max: number, window: number): void;
|
|
11
|
+
export declare function readBody(request: IncomingMessage, max: number, signal: AbortSignal): Promise<Buffer>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { MapQueryError } from './types.js';
|
|
2
|
+
import { fail } from './boundary.js';
|
|
3
|
+
const CODES = new Map([
|
|
4
|
+
['MAP_UNAUTHENTICATED', 401], ['MAP_REQUEST_DENIED', 403], ['MAP_NOT_FOUND', 404],
|
|
5
|
+
['MAP_REQUEST_INVALID', 400], ['MAP_ARGUMENT_INVALID', 400], ['MAP_OPERATION_INVALID', 400],
|
|
6
|
+
['MAP_SCOPE_INVALID', 403], ['MAP_LIMIT_EXCEEDED', 413], ['MAP_REQUEST_TOO_LARGE', 413],
|
|
7
|
+
['MAP_RESPONSE_TOO_LARGE', 413], ['MAP_TIMEOUT', 408], ['MAP_BUSY', 503],
|
|
8
|
+
['MAP_RATE_LIMITED', 429], ['MAP_UNAVAILABLE', 503],
|
|
9
|
+
['MAP_SESSION_REQUIRED', 401], ['MAP_IDENTITY_INVALID', 401], ['MAP_IDENTITY_UNAVAILABLE', 503],
|
|
10
|
+
]);
|
|
11
|
+
export function safeError(error) {
|
|
12
|
+
return error instanceof MapQueryError && CODES.has(error.code) ? error.code : 'MAP_UNAVAILABLE';
|
|
13
|
+
}
|
|
14
|
+
export function headers(response, size, mime = 'application/json') {
|
|
15
|
+
response.setHeader('Content-Type', mime);
|
|
16
|
+
response.setHeader('Content-Length', size);
|
|
17
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
18
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
19
|
+
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
20
|
+
response.setHeader('Connection', 'close');
|
|
21
|
+
response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
|
|
22
|
+
}
|
|
23
|
+
export function errorResponse(response, code) {
|
|
24
|
+
if (response.writableEnded || response.destroyed)
|
|
25
|
+
return;
|
|
26
|
+
if (response.headersSent) {
|
|
27
|
+
response.destroy();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const body = Buffer.from(JSON.stringify({ error: code }));
|
|
31
|
+
headers(response, body.length);
|
|
32
|
+
if (code === 'MAP_UNAUTHENTICATED')
|
|
33
|
+
response.setHeader('WWW-Authenticate', 'Bearer realm="agent-guard-reference"');
|
|
34
|
+
response.writeHead(CODES.get(code) ?? 503);
|
|
35
|
+
response.end(body);
|
|
36
|
+
}
|
|
37
|
+
export const count = (request, name) => request.rawHeaders.filter((entry, i) => i % 2 === 0 && entry.toLowerCase() === name).length;
|
|
38
|
+
export function consume(bucket, max, window) {
|
|
39
|
+
const now = performance.now();
|
|
40
|
+
if (now - bucket.since >= window) {
|
|
41
|
+
bucket.since = now;
|
|
42
|
+
bucket.count = 0;
|
|
43
|
+
}
|
|
44
|
+
if (++bucket.count > max)
|
|
45
|
+
fail('MAP_RATE_LIMITED');
|
|
46
|
+
}
|
|
47
|
+
export function readBody(request, max, signal) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let size = 0;
|
|
50
|
+
const chunks = [];
|
|
51
|
+
const cleanup = () => { request.off('data', data); request.off('end', end); request.off('error', aborted); request.off('aborted', aborted); signal.removeEventListener('abort', timeout); };
|
|
52
|
+
const stop = (code) => { cleanup(); request.pause(); reject(new MapQueryError(code)); };
|
|
53
|
+
const data = (chunk) => { size += chunk.length; if (size > max) {
|
|
54
|
+
stop('MAP_REQUEST_TOO_LARGE');
|
|
55
|
+
return;
|
|
56
|
+
} chunks.push(chunk); };
|
|
57
|
+
const end = () => { cleanup(); resolve(Buffer.concat(chunks, size)); };
|
|
58
|
+
const aborted = () => stop('MAP_REQUEST_INVALID');
|
|
59
|
+
const timeout = () => stop('MAP_TIMEOUT');
|
|
60
|
+
if (signal.aborted) {
|
|
61
|
+
timeout();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
request.on('data', data);
|
|
65
|
+
request.once('end', end);
|
|
66
|
+
request.once('error', aborted);
|
|
67
|
+
request.once('aborted', aborted);
|
|
68
|
+
signal.addEventListener('abort', timeout, { once: true });
|
|
69
|
+
});
|
|
70
|
+
}
|