@shieldaai/agent-map 0.1.0-alpha.3 → 1.1.0
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/README.md +652 -272
- package/dist/assets.js +16 -26
- package/dist/boundary-flags.d.ts +73 -0
- package/dist/boundary-flags.js +352 -0
- package/dist/boundary.d.ts +18 -3
- package/dist/boundary.js +71 -21
- package/dist/cli-entry.d.ts +11 -0
- package/dist/cli-entry.js +48 -0
- package/dist/cli.js +40 -43
- package/dist/common.d.ts +34 -0
- package/dist/common.js +94 -0
- package/dist/http.js +78 -51
- package/dist/identity-cli.js +93 -46
- package/dist/identity-http.js +226 -94
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/mcp.js +40 -5
- package/dist/oidc.d.ts +5 -0
- package/dist/oidc.js +121 -38
- package/dist/policy-reader.d.ts +56 -3
- package/dist/policy-reader.js +823 -110
- package/dist/projection-fields.d.ts +4 -0
- package/dist/projection-fields.js +144 -0
- package/dist/service/changes.d.ts +5 -0
- package/dist/service/changes.js +255 -0
- package/dist/service/context.d.ts +33 -0
- package/dist/service/context.js +2 -0
- package/dist/service/discovery.d.ts +6 -0
- package/dist/service/discovery.js +31 -0
- package/dist/service/explain.d.ts +12 -0
- package/dist/service/explain.js +126 -0
- package/dist/service/flags.d.ts +20 -0
- package/dist/service/flags.js +70 -0
- package/dist/service/inventory.d.ts +4 -0
- package/dist/service/inventory.js +62 -0
- package/dist/service/json.d.ts +8 -0
- package/dist/service/json.js +7 -0
- package/dist/service/operations/discovery.d.ts +5 -0
- package/dist/service/operations/discovery.js +90 -0
- package/dist/service/operations/explain-access.d.ts +3 -0
- package/dist/service/operations/explain-access.js +25 -0
- package/dist/service/operations/find-agents-with-access.d.ts +3 -0
- package/dist/service/operations/find-agents-with-access.js +120 -0
- package/dist/service/operations/get-agent-access.d.ts +3 -0
- package/dist/service/operations/get-agent-access.js +75 -0
- package/dist/service/operations/get-agent-inventory.d.ts +3 -0
- package/dist/service/operations/get-agent-inventory.js +42 -0
- package/dist/service/operations/get-changes.d.ts +3 -0
- package/dist/service/operations/get-changes.js +11 -0
- package/dist/service/operations/get-inventory-health.d.ts +3 -0
- package/dist/service/operations/get-inventory-health.js +81 -0
- package/dist/service/operations/get-resource-access.d.ts +3 -0
- package/dist/service/operations/get-resource-access.js +48 -0
- package/dist/service/operations/list-agents.d.ts +3 -0
- package/dist/service/operations/list-agents.js +92 -0
- package/dist/service/operations/list-owners-resources.d.ts +5 -0
- package/dist/service/operations/list-owners-resources.js +69 -0
- package/dist/service/ordering.d.ts +9 -0
- package/dist/service/ordering.js +47 -0
- package/dist/service/outside-guard.d.ts +68 -0
- package/dist/service/outside-guard.js +313 -0
- package/dist/service/policy.d.ts +26 -0
- package/dist/service/policy.js +299 -0
- package/dist/service/schemas.d.ts +16 -0
- package/dist/service/schemas.js +192 -0
- package/dist/service/summaries.d.ts +32 -0
- package/dist/service/summaries.js +247 -0
- package/dist/service/work.d.ts +33 -0
- package/dist/service/work.js +116 -0
- package/dist/service.d.ts +5 -5
- package/dist/service.js +110 -645
- package/dist/store/ancestors.d.ts +9 -0
- package/dist/store/ancestors.js +86 -0
- package/dist/store/discovery.d.ts +8 -0
- package/dist/store/discovery.js +288 -0
- package/dist/store/evidence.d.ts +12 -0
- package/dist/store/evidence.js +90 -0
- package/dist/store/identity-scope.d.ts +9 -0
- package/dist/store/identity-scope.js +71 -0
- package/dist/store/nodes.d.ts +24 -0
- package/dist/store/nodes.js +237 -0
- package/dist/store/projection.d.ts +19 -0
- package/dist/store/projection.js +242 -0
- package/dist/store/queries.d.ts +48 -0
- package/dist/store/queries.js +302 -0
- package/dist/store/read.d.ts +81 -0
- package/dist/store/read.js +135 -0
- package/dist/store/selection.d.ts +3 -0
- package/dist/store/selection.js +102 -0
- package/dist/store.d.ts +15 -0
- package/dist/store.js +102 -395
- package/dist/transport.d.ts +42 -2
- package/dist/transport.js +144 -16
- package/dist/types.d.ts +184 -4
- package/dist/types.js +52 -11
- package/package.json +16 -3
package/dist/store.js
CHANGED
|
@@ -1,422 +1,112 @@
|
|
|
1
|
-
import { isIP } from 'node:net';
|
|
2
1
|
import neo4j, {} from 'neo4j-driver';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 {};
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { immutableJson } from '@shieldaai/agent-guard/internal';
|
|
4
|
+
import { integer, shortenScopeList } from './common.js';
|
|
5
|
+
import { loadAncestors } from './store/ancestors.js';
|
|
6
|
+
import { readDiscovery } from './store/discovery.js';
|
|
7
|
+
import { verifyEvidence } from './store/evidence.js';
|
|
8
|
+
import { loadIdentityScope } from './store/identity-scope.js';
|
|
9
|
+
import { readSources } from './store/projection.js';
|
|
10
|
+
import { MAX_STORE_BYTES, MAX_STORE_EDGES, MAX_STORE_NODES } from './store/queries.js';
|
|
11
|
+
import { StoreRead } from './store/read.js';
|
|
12
|
+
import { validateSelection } from './store/selection.js';
|
|
13
|
+
import { mapIdentifier, MapQueryError, validateReadScope, } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* The view as the store returns it. The identity-scope bucket lists are optional detail: if
|
|
16
|
+
* the view does not fit with them they are dropped (their statuses stand; see
|
|
17
|
+
* `shortenScopeList`) before the read is refused.
|
|
18
|
+
*/
|
|
19
|
+
function freezeView(sources, limits) {
|
|
20
|
+
const freeze = (items) => immutableJson({ sources: items }, 16, limits.values, limits.bytes);
|
|
141
21
|
try {
|
|
142
|
-
|
|
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;
|
|
22
|
+
return freeze(sources);
|
|
159
23
|
}
|
|
160
24
|
catch {
|
|
161
|
-
|
|
25
|
+
const bare = (records) => records.map(record => shortenScopeList(record, 0));
|
|
26
|
+
const lean = sources.map(item => ({
|
|
27
|
+
...item,
|
|
28
|
+
current: {
|
|
29
|
+
...item.current,
|
|
30
|
+
...(item.current.identityScope ? { identityScope: bare(item.current.identityScope) } : {}),
|
|
31
|
+
...(item.current.identityReach ? { identityReach: bare(item.current.identityReach) } : {}),
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
try {
|
|
35
|
+
return freeze(lean);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new MapQueryError('MAP_LIMIT_EXCEEDED');
|
|
39
|
+
}
|
|
162
40
|
}
|
|
163
41
|
}
|
|
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
42
|
export class Neo4jMapStore {
|
|
194
43
|
#driver;
|
|
195
44
|
#database;
|
|
196
45
|
#timeout;
|
|
197
|
-
#
|
|
198
|
-
#nodes;
|
|
199
|
-
#edges;
|
|
200
|
-
#policyAge;
|
|
46
|
+
#limits;
|
|
201
47
|
#clock;
|
|
202
48
|
#active = 0;
|
|
203
49
|
constructor(driver, options = {}) {
|
|
204
50
|
this.#driver = driver;
|
|
205
|
-
this.#database = mapIdentifier(options.database ?? 'neo4j');
|
|
206
|
-
this.#timeout =
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
51
|
+
this.#database = mapIdentifier(options.database ?? 'neo4j', 'MAP_CONFIG_INVALID');
|
|
52
|
+
this.#timeout = integer(options.timeoutMs, 5000, 1, 5000);
|
|
53
|
+
const sources = integer(options.maxSources, 64, 1, 64);
|
|
54
|
+
const nodes = integer(options.maxNodes, 2000, 1, MAX_STORE_NODES);
|
|
55
|
+
const edges = integer(options.maxEdges, 5000, 1, MAX_STORE_EDGES);
|
|
56
|
+
const bytes = integer(options.maxBytes, 4 * 1024 * 1024, 1, MAX_STORE_BYTES);
|
|
57
|
+
this.#limits = {
|
|
58
|
+
sources,
|
|
59
|
+
nodes,
|
|
60
|
+
edges,
|
|
61
|
+
bytes,
|
|
62
|
+
// JSON values the final copy may hold: the long-standing 200,000, or what the configured
|
|
63
|
+
// node and edge limits need (about 30 values per node and 4 per edge).
|
|
64
|
+
values: Math.max(200_000, nodes * 30 + edges * 4),
|
|
65
|
+
policyAge: integer(options.policyMaxAgeSeconds, 7200, 1, 86400),
|
|
66
|
+
};
|
|
211
67
|
if (options.clock !== undefined && typeof options.clock !== 'function')
|
|
212
|
-
throw new MapQueryError('
|
|
68
|
+
throw new MapQueryError('MAP_CONFIG_INVALID');
|
|
213
69
|
this.#clock = options.clock ?? Date.now;
|
|
214
70
|
}
|
|
215
|
-
async close() {
|
|
71
|
+
async close() {
|
|
72
|
+
await this.#driver.close();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* One bounded, read-only transaction. The phases run in a fixed order: the source rows, then
|
|
76
|
+
* either a discovery read or each source's projections, delegation ancestors and identity
|
|
77
|
+
* scope; the sources are rechecked and the evidence read again before the view is returned.
|
|
78
|
+
*/
|
|
216
79
|
async read(input, request = {}) {
|
|
217
80
|
const scope = validateReadScope(input);
|
|
218
|
-
|
|
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
|
-
}
|
|
81
|
+
const selection = validateSelection(scope, request);
|
|
237
82
|
if (this.#active >= 8)
|
|
238
83
|
throw new MapQueryError('MAP_UNAVAILABLE');
|
|
239
84
|
this.#active++;
|
|
240
85
|
const deadline = Date.now() + this.#timeout;
|
|
241
86
|
let cancel = () => { };
|
|
242
|
-
const check = () => { if (Date.now() >= deadline)
|
|
243
|
-
throw new MapQueryError('MAP_TIMEOUT'); };
|
|
244
87
|
const work = (async () => {
|
|
245
|
-
const session = this.#driver.session({
|
|
88
|
+
const session = this.#driver.session({
|
|
89
|
+
database: this.#database,
|
|
90
|
+
defaultAccessMode: neo4j.session.READ,
|
|
91
|
+
fetchSize: 32,
|
|
92
|
+
});
|
|
246
93
|
let transaction;
|
|
247
94
|
try {
|
|
248
95
|
transaction = session.beginTransaction({ timeout: this.#timeout });
|
|
249
96
|
const tx = transaction;
|
|
250
|
-
cancel = () => {
|
|
251
|
-
|
|
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');
|
|
97
|
+
cancel = () => {
|
|
98
|
+
void tx.rollback().catch(() => { });
|
|
277
99
|
};
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
}
|
|
100
|
+
const read = new StoreRead(scope, selection, this.#limits, this.#clock, tx, deadline);
|
|
101
|
+
await read.selectSources();
|
|
102
|
+
if (selection.undeclared === true || selection.unclaimedIdentities === true)
|
|
103
|
+
return await readDiscovery(read);
|
|
104
|
+
const sources = await readSources(read);
|
|
105
|
+
await loadAncestors(read, sources);
|
|
106
|
+
await loadIdentityScope(read, sources);
|
|
107
|
+
await read.recheckSources();
|
|
108
|
+
await verifyEvidence(read);
|
|
109
|
+
return freezeView(sources, this.#limits);
|
|
420
110
|
}
|
|
421
111
|
catch (error) {
|
|
422
112
|
if (error instanceof MapQueryError)
|
|
@@ -430,9 +120,18 @@ export class Neo4jMapStore {
|
|
|
430
120
|
await session.close().catch(() => { });
|
|
431
121
|
}
|
|
432
122
|
})();
|
|
433
|
-
void work
|
|
123
|
+
void work
|
|
124
|
+
.finally(() => {
|
|
125
|
+
this.#active--;
|
|
126
|
+
})
|
|
127
|
+
.catch(() => { });
|
|
434
128
|
let timer;
|
|
435
|
-
const timeout = new Promise((_resolve, reject) => {
|
|
129
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
130
|
+
timer = setTimeout(() => {
|
|
131
|
+
cancel();
|
|
132
|
+
reject(new MapQueryError('MAP_TIMEOUT'));
|
|
133
|
+
}, this.#timeout);
|
|
134
|
+
});
|
|
436
135
|
try {
|
|
437
136
|
return await Promise.race([work, timeout]);
|
|
438
137
|
}
|
|
@@ -447,20 +146,28 @@ export function createNeo4jMapStore(options) {
|
|
|
447
146
|
const uri = new URL(options.uri);
|
|
448
147
|
const host = uri.hostname.replace(/^\[|\]$/g, '');
|
|
449
148
|
const local = isIP(host) === 4 ? host.startsWith('127.') : host === '::1';
|
|
450
|
-
if (!['bolt:', 'bolt+s:', 'neo4j+s:'].includes(uri.protocol) ||
|
|
451
|
-
|
|
149
|
+
if (!['bolt:', 'bolt+s:', 'neo4j+s:'].includes(uri.protocol) ||
|
|
150
|
+
!host ||
|
|
151
|
+
uri.username ||
|
|
152
|
+
uri.password ||
|
|
153
|
+
uri.pathname ||
|
|
154
|
+
uri.search ||
|
|
155
|
+
uri.hash ||
|
|
156
|
+
(uri.port && (+uri.port < 1 || +uri.port > 65535)) ||
|
|
452
157
|
(uri.protocol === 'bolt:' && !local))
|
|
453
158
|
throw new Error();
|
|
454
159
|
mapIdentifier(options.username);
|
|
455
160
|
if (typeof options.password !== 'string' || !options.password || options.password.length > 8192)
|
|
456
161
|
throw new Error();
|
|
457
162
|
const driver = neo4j.driver(options.uri, neo4j.auth.basic(options.username, options.password), {
|
|
458
|
-
connectionTimeout: 5000,
|
|
163
|
+
connectionTimeout: 5000,
|
|
164
|
+
connectionAcquisitionTimeout: 5000,
|
|
165
|
+
maxTransactionRetryTime: 0,
|
|
459
166
|
maxConnectionPoolSize: 8,
|
|
460
167
|
});
|
|
461
168
|
return new Neo4jMapStore(driver, options);
|
|
462
169
|
}
|
|
463
170
|
catch {
|
|
464
|
-
throw new MapQueryError('
|
|
171
|
+
throw new MapQueryError('MAP_CONFIG_INVALID');
|
|
465
172
|
}
|
|
466
173
|
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
1
|
+
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
|
2
2
|
export declare function safeError(error: unknown): string;
|
|
3
|
+
/**
|
|
4
|
+
* The status of a timeout. 408 says the client was too slow to send its request; once the whole
|
|
5
|
+
* request has been received, the time was spent by the service or its database, which is 504.
|
|
6
|
+
*/
|
|
7
|
+
export declare const timeoutStatus: (received: boolean) => number | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Answers a failed request with its safe code; nothing from the error itself is sent.
|
|
10
|
+
* `received`: the request body had been read in full when the failure happened.
|
|
11
|
+
*/
|
|
12
|
+
export declare function respondError(response: ServerResponse, error: unknown, received?: boolean): void;
|
|
3
13
|
export declare function headers(response: ServerResponse, size: number, mime?: string): void;
|
|
4
|
-
|
|
14
|
+
/** `status` overrides the code's usual status, for a refusal HTTP names more exactly (417). */
|
|
15
|
+
export declare function errorResponse(response: ServerResponse, code: string, status?: number): void;
|
|
5
16
|
export declare const count: (request: IncomingMessage, name: string) => number;
|
|
6
17
|
export type Bucket = {
|
|
7
18
|
since: number;
|
|
@@ -9,3 +20,32 @@ export type Bucket = {
|
|
|
9
20
|
};
|
|
10
21
|
export declare function consume(bucket: Bucket, max: number, window: number): void;
|
|
11
22
|
export declare function readBody(request: IncomingMessage, max: number, signal: AbortSignal): Promise<Buffer>;
|
|
23
|
+
/** Per-credential or per-session rate limit: `maxRequests` in each `windowMs`. */
|
|
24
|
+
export declare function rateLimit(value: unknown, defaultMaxRequests: number): {
|
|
25
|
+
maxRequests: number;
|
|
26
|
+
windowMs: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The JSON body of an admitted POST. Call it only after the credential (or session and CSRF)
|
|
30
|
+
* and rate checks have passed: it is what tells a client that sent `Expect: 100-continue` to go
|
|
31
|
+
* on, and a refusal before it has already answered with its own status.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readJsonBody(request: IncomingMessage, response: ServerResponse, limit: number, signal: AbortSignal): Promise<unknown>;
|
|
34
|
+
/** The `{ operation, args }` envelope of `POST /v1/query`. */
|
|
35
|
+
export declare function queryEnvelope(parsed: unknown): {
|
|
36
|
+
operation: unknown;
|
|
37
|
+
args: unknown;
|
|
38
|
+
};
|
|
39
|
+
/** A body that must be the empty object, as the sign-in and sign-out routes require. */
|
|
40
|
+
export declare function emptyObject(parsed: unknown): void;
|
|
41
|
+
/** Sends one fixed viewer asset. */
|
|
42
|
+
export declare function sendAsset(response: ServerResponse, asset: {
|
|
43
|
+
body: Buffer;
|
|
44
|
+
mime: string;
|
|
45
|
+
}): void;
|
|
46
|
+
/**
|
|
47
|
+
* What neither server speaks: a request that arrives with `Expect: 100-continue` still goes
|
|
48
|
+
* through the normal handler, any other expectation is 417, and tunnels, upgrades and
|
|
49
|
+
* malformed requests are dropped.
|
|
50
|
+
*/
|
|
51
|
+
export declare function refuseOtherTraffic(server: Server, handle: (request: IncomingMessage, response: ServerResponse) => Promise<void>): void;
|