@swfte/nexus-sdk 0.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/LICENSE +201 -0
- package/NOTICE +38 -0
- package/README.md +414 -0
- package/ai.d.ts +84 -0
- package/index.d.ts +433 -0
- package/otel.d.ts +80 -0
- package/package.json +93 -0
- package/policy.d.ts +141 -0
- package/src/ai.cjs +334 -0
- package/src/ai.js +39 -0
- package/src/core.cjs +2411 -0
- package/src/health.cjs +172 -0
- package/src/index.cjs +53 -0
- package/src/index.js +151 -0
- package/src/otel/bridge.cjs +257 -0
- package/src/otel/classify.cjs +166 -0
- package/src/otel/index.cjs +84 -0
- package/src/otel/index.js +39 -0
- package/src/otel/semconv.cjs +650 -0
- package/src/policy/engine.cjs +368 -0
- package/src/policy/envelope.cjs +256 -0
- package/src/policy/index.cjs +224 -0
- package/src/policy/rules.cjs +442 -0
- package/src/pricing.cjs +188 -0
- package/src/provenance.cjs +304 -0
- package/src/redact.cjs +734 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* The enforcement seam — the one thing in this product that can say *no*.
|
|
4
|
+
*
|
|
5
|
+
* Capture of coding agents is now free: OpenLIT is Apache-2.0, `opentelemetry-hooks` is MIT, and
|
|
6
|
+
* ddtrace, OpenLLMetry and OpenInference all read the same provider call sites we do. **None of
|
|
7
|
+
* them can stop a call.** Every agent-observability vendor observes; the differentiator is not what
|
|
8
|
+
* we see, it is that we can refuse. That is what is implemented here.
|
|
9
|
+
*
|
|
10
|
+
* import * as nexus from '@swfte/nexus-sdk';
|
|
11
|
+
* import * as policy from '@swfte/nexus-sdk/policy';
|
|
12
|
+
*
|
|
13
|
+
* policy.install(signedEnvelope); // verified before a single rule is read
|
|
14
|
+
*
|
|
15
|
+
* const d = policy.decide('tool_action', { tool: 'db.write', target: 'prod-orders' });
|
|
16
|
+
* if (d.denied) { … }
|
|
17
|
+
*
|
|
18
|
+
* policy.gate('tool_action', { tool: 'db.write', target: 'prod-orders' }, () => {
|
|
19
|
+
* writeTheRows(); // never runs if an enforcing rule denies
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* ── Where enforcement actually happens, and where it cannot ──────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* **`run.action(...)` is gated.** It is the only call site in this SDK that is, and that is not an
|
|
25
|
+
* oversight — it is the only one where the customer owns the call site and a decision can be taken
|
|
26
|
+
* before the effect.
|
|
27
|
+
*
|
|
28
|
+
* **An application on the Vercel AI SDK seam cannot be gated in-process, now or later.** That
|
|
29
|
+
* seam's callbacks return `void`: there is no channel to refuse on, and a throw from
|
|
30
|
+
* `onToolExecutionStart` neither stops the tool nor reaches the application — measured, repeatedly.
|
|
31
|
+
* There is deliberately no policy hook on `telemetry()` and there will not be one, because a hook
|
|
32
|
+
* there would be a capability that appears to work and silently does not. Enforcement in Node means
|
|
33
|
+
* the explicit API, a gateway in front of the provider, or nothing.
|
|
34
|
+
*
|
|
35
|
+
* ── What raises ──────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* `Denied` is the single exception this SDK will ever put in a host stack trace, and the host opted
|
|
38
|
+
* into it twice: the rule carried `enforce: true` and the call site did not decline. With no policy
|
|
39
|
+
* installed — the default — nothing here can throw, so adding this import changes no behaviour
|
|
40
|
+
* until a signed envelope arrives.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const core = require('../core.cjs');
|
|
44
|
+
const { Engine, Denied, ALERTS, ALLOW, DENY } = require('./engine.cjs');
|
|
45
|
+
const rules = require('./rules.cjs');
|
|
46
|
+
const envelope = require('./envelope.cjs');
|
|
47
|
+
|
|
48
|
+
let _engine = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The process-wide engine.
|
|
52
|
+
*
|
|
53
|
+
* Alerts are routed into the ledger as `policy_alert` events, which is the point of having them:
|
|
54
|
+
* an integrity failure that only exists as a counter is a failure nobody is looking at. The alert
|
|
55
|
+
* path is guarded and never re-enters the engine, because an alert raised while deciding must not
|
|
56
|
+
* be able to make deciding fail.
|
|
57
|
+
*/
|
|
58
|
+
function engine() {
|
|
59
|
+
if (_engine === null) {
|
|
60
|
+
_engine = new Engine({
|
|
61
|
+
settings: settingsFromEnv(),
|
|
62
|
+
onAlert: (kind, message, extra) => emitAlert(kind, message, extra),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return _engine;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function settingsFromEnv() {
|
|
69
|
+
const flag = (name, dflt) => {
|
|
70
|
+
const v = process.env[name];
|
|
71
|
+
if (v === undefined || String(v).trim() === '') return dflt;
|
|
72
|
+
return !['0', 'false', 'no', 'off'].includes(String(v).trim().toLowerCase());
|
|
73
|
+
};
|
|
74
|
+
const num = (name, dflt) => {
|
|
75
|
+
const v = parseFloat(process.env[name]);
|
|
76
|
+
return Number.isFinite(v) && v > 0 ? v : dflt;
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
enforcementEnabled: flag('NEXUS_POLICY_ENABLED', true),
|
|
80
|
+
failClosed: flag('NEXUS_POLICY_FAIL_CLOSED', false),
|
|
81
|
+
decisionBudgetMs: num('NEXUS_POLICY_BUDGET_MS', undefined),
|
|
82
|
+
pubkeyHex: (process.env.NEXUS_POLICY_PUBKEY || '').trim() || null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* In-process alert state, deduplicated by kind.
|
|
88
|
+
*
|
|
89
|
+
* A ring rather than a stream, because the alerts that matter are the ones that keep happening: a
|
|
90
|
+
* fleet whose policy will not verify raises `bad_signature` on every install attempt, and a
|
|
91
|
+
* thousand identical events answer no question a `count` does not answer better. Bounded, because
|
|
92
|
+
* the kinds are a closed set but a bug that invented one must not become unbounded memory.
|
|
93
|
+
*/
|
|
94
|
+
const _alerts = new Map();
|
|
95
|
+
const MAX_ALERTS = 64;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Emit one `policy_alert`, deduplicated.
|
|
99
|
+
*
|
|
100
|
+
* **Only the fields `contract/events.v1.json` declares for this type**: `kind`, `detail`, `count`,
|
|
101
|
+
* `first_seen`, `last_seen`. An earlier draft of this function invented `alert`, `reason`,
|
|
102
|
+
* `rule_id`, `subject_kind` and `detected_by` — all of which the schema would have accepted, since
|
|
103
|
+
* `additionalProperties` is true, and none of which any reader could validate. That is the same
|
|
104
|
+
* drift as `data_expectation`, one level down: a producer writing fields the ledger has no model
|
|
105
|
+
* for. The rule-level detail rides inside `detail`, where a human can read it, rather than in
|
|
106
|
+
* invented columns nothing will ever query.
|
|
107
|
+
*
|
|
108
|
+
* `behavior_trace`, matching Python: the class answers *how we know this record exists*, and the
|
|
109
|
+
* alert being raised is something this process observed itself doing.
|
|
110
|
+
*
|
|
111
|
+
* The event pipeline is the **least** important destination for an alert. If reporting a broken
|
|
112
|
+
* policy required a working collector, the case where both are broken — the case that matters —
|
|
113
|
+
* would be the silent one. So this is best-effort and wrapped whole; the counters are the
|
|
114
|
+
* destination that cannot fail.
|
|
115
|
+
*/
|
|
116
|
+
function emitAlert(kind, message, extra) {
|
|
117
|
+
core.guard('policy.alert', () => {
|
|
118
|
+
const nowS = Date.now() / 1000;
|
|
119
|
+
let entry = _alerts.get(kind);
|
|
120
|
+
if (entry === undefined) {
|
|
121
|
+
if (_alerts.size >= MAX_ALERTS) {
|
|
122
|
+
// A full ring means something is very wrong; drop the oldest by first_seen so the newest
|
|
123
|
+
// signal is the one that survives.
|
|
124
|
+
let oldestKey = null;
|
|
125
|
+
let oldest = Infinity;
|
|
126
|
+
for (const [k, v] of _alerts) if (v.firstSeen < oldest) { oldest = v.firstSeen; oldestKey = k; }
|
|
127
|
+
if (oldestKey !== null) _alerts.delete(oldestKey);
|
|
128
|
+
}
|
|
129
|
+
entry = { count: 0, firstSeen: nowS, lastSeen: nowS };
|
|
130
|
+
_alerts.set(kind, entry);
|
|
131
|
+
}
|
|
132
|
+
entry.count += 1;
|
|
133
|
+
entry.lastSeen = nowS;
|
|
134
|
+
|
|
135
|
+
const client = core.ensureClient();
|
|
136
|
+
if (!client) return;
|
|
137
|
+
|
|
138
|
+
// The detail is authored by this SDK, never by a customer or a model, so it is a label rather
|
|
139
|
+
// than content and does not go through the tier ladder. Bounded all the same.
|
|
140
|
+
let detail = String(message || '');
|
|
141
|
+
if (extra && extra.rule_id) detail += ' [rule ' + String(extra.rule_id) + ']';
|
|
142
|
+
if (extra && extra.subject_kind) detail += ' [kind ' + String(extra.subject_kind) + ']';
|
|
143
|
+
|
|
144
|
+
client.emit(core.base('policy_alert', client.sessionId, client.cfg, 'behavior_trace', {
|
|
145
|
+
kind: String(kind).slice(0, 64),
|
|
146
|
+
detail: detail.slice(0, 256),
|
|
147
|
+
count: entry.count,
|
|
148
|
+
first_seen: new Date(entry.firstSeen * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
149
|
+
last_seen: new Date(entry.lastSeen * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
150
|
+
}));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Every alert this process has raised, with its count and window. */
|
|
155
|
+
function alerts() {
|
|
156
|
+
return core.guard('policy.alerts', () => Array.from(_alerts.entries())
|
|
157
|
+
.map(([kind, a]) => ({ kind, count: a.count, firstSeen: a.firstSeen, lastSeen: a.lastSeen })), []);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Verify and install a signed policy envelope.
|
|
162
|
+
*
|
|
163
|
+
* Failure is *no policy*, which allows — never a denial. A control plane that pushed a bad envelope
|
|
164
|
+
* must not take a customer's service down as the punishment.
|
|
165
|
+
*/
|
|
166
|
+
function install(raw, pubkeyHex) {
|
|
167
|
+
return core.guard('policy.install', () => engine().install(raw, pubkeyHex),
|
|
168
|
+
{ installed: false, problem: 'install raised', state: null, rules: 0, quarantined: [] });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Adjust settings at runtime. Returns the effective settings. */
|
|
172
|
+
function configure(patch) {
|
|
173
|
+
return core.guard('policy.configure', () => engine().configure(patch), null);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Evaluate a subject. **Never throws.** */
|
|
177
|
+
function decide(kind, subject, opts) {
|
|
178
|
+
// The fallback is a real allow-shaped decision, not null: every caller reads `.denied`, and a
|
|
179
|
+
// null here would turn a contained guard failure into a TypeError one frame later — which is the
|
|
180
|
+
// exact shape `guard` exists to prevent.
|
|
181
|
+
return core.guard('policy.decide', () => engine().decide(kind, subject, opts),
|
|
182
|
+
{ action: ALLOW, enforced: false, denied: false, ruleId: null, reason: '',
|
|
183
|
+
source: 'error', stale: false, timedOut: false, latencyMs: 0 });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Decide, and throw `Denied` if an enforcing rule refuses. */
|
|
187
|
+
function check(kind, subject, opts) {
|
|
188
|
+
const d = decide(kind, subject, opts);
|
|
189
|
+
if (d && d.denied) throw new Denied(d);
|
|
190
|
+
return d;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Guard a function: decides **first**, then calls it. Throws `Denied` instead of running it. */
|
|
194
|
+
function gate(kind, subject, fn, opts) {
|
|
195
|
+
const d = check(kind, subject, opts);
|
|
196
|
+
return fn(d);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** What the engine has done: evaluations, denials, budget overruns, alerts by kind. */
|
|
200
|
+
function counters() {
|
|
201
|
+
return core.guard('policy.counters', () => Object.assign({}, engine().counters), {});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Forget the installed policy and every counter. Chiefly for tests. */
|
|
205
|
+
function reset() {
|
|
206
|
+
if (_engine !== null) _engine.reset();
|
|
207
|
+
_engine = null;
|
|
208
|
+
_alerts.clear();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Arm the one gated call site ──────────────────────────────────────────────────────────────
|
|
212
|
+
//
|
|
213
|
+
// Importing this module is what gates `run.action()`. That is deliberate and it is the whole
|
|
214
|
+
// opt-in: a customer who never imports `@swfte/nexus-sdk/policy` has no policy code on their
|
|
215
|
+
// request path at all, and one who does has said, in an import, that they want their actions
|
|
216
|
+
// subject to a signed policy.
|
|
217
|
+
//
|
|
218
|
+
// `check` rather than `decide`: the gate has to be able to throw, or it is not a gate.
|
|
219
|
+
core.setGateHook((subject) => check(subject.kind, subject));
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
install, configure, decide, check, gate, counters, alerts, reset, engine,
|
|
223
|
+
Denied, ALERTS, ALLOW, DENY, rules, envelope,
|
|
224
|
+
};
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Rules: the shape, the matcher, and the three asymmetries that make the matcher safe.
|
|
4
|
+
*
|
|
5
|
+
* A port of `nexus/policy/rules.py`. **Unshipped** — see `test/policy-envelope.test.mjs` and
|
|
6
|
+
* PARITY.md §6. Nothing in this SDK is gated by these rules yet; they exist so the engine can be
|
|
7
|
+
* built on top of a matcher that is already correct.
|
|
8
|
+
*
|
|
9
|
+
* ── Asymmetry one: `enforce` is a property of the rule, not of the caller ────────────────────
|
|
10
|
+
*
|
|
11
|
+
* A rule denies for real only if it carries `"enforce": true`. Everything else evaluates, records,
|
|
12
|
+
* and lets the call through. One flag rather than two, because a mode that could be inferred from
|
|
13
|
+
* context is a mode somebody will infer wrongly at 3am. A call site cannot promote an unmarked rule
|
|
14
|
+
* to enforcing; it can only decline to enforce a marked one.
|
|
15
|
+
*
|
|
16
|
+
* ── Asymmetry two: unknown fields ────────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* **An unknown field never satisfies an `allow` clause, but does not save a subject from a `deny`
|
|
19
|
+
* clause.** `branch_not: ["main"]` against a subject with no branch must not read as *"well, it
|
|
20
|
+
* isn't main"*. That step, from "I don't know" to "allow", is the one this package exists to
|
|
21
|
+
* refuse. The mirror holds too: refusing under uncertainty is never the unsafe direction, so a
|
|
22
|
+
* `deny` rule whose clause references a missing field still denies. One predicate, two polarities,
|
|
23
|
+
* decided by the rule's own action.
|
|
24
|
+
*
|
|
25
|
+
* ── Asymmetry three: normalisation and glob strictness follow the same polarity ──────────────
|
|
26
|
+
*
|
|
27
|
+
* An exactly case-sensitive `fnmatch` matcher gives an attacker two free evasions:
|
|
28
|
+
* `target_contains: ["prod"]` misses `PROD`, and because `*` crosses every character including
|
|
29
|
+
* `/` and `?`, the *allow* rule `target_glob: "https://*.safe.com/*"` matches
|
|
30
|
+
* `https://evil.com/a?z=.safe.com/b`. Both are fixed, and each fix moves only in the direction
|
|
31
|
+
* that is safe for its rule's own polarity:
|
|
32
|
+
*
|
|
33
|
+
* - For `deny` / `require_approval`, both sides are NFKC-normalised and case-folded. That can only
|
|
34
|
+
* make a refusing rule match *more*.
|
|
35
|
+
* - For `allow`, both sides stay case-sensitive *and* `*`/`?` stop crossing the structural
|
|
36
|
+
* delimiters `/ ? # @`. Both changes can only make a permitting rule match *less*.
|
|
37
|
+
*
|
|
38
|
+
* They are opposite rather than uniform because "casefold everything" would widen allow rules, and
|
|
39
|
+
* a widened allow is an authorisation bug. `branch_not` inverts again, because it is a negative
|
|
40
|
+
* clause, and it is handled where it lives.
|
|
41
|
+
*
|
|
42
|
+
* ── Quarantine ──────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
*
|
|
44
|
+
* Malformed rules are quarantined **individually**, never fatal to the envelope. Rejecting a whole
|
|
45
|
+
* rule set over one typo means a single bad character in a control-plane deploy disarms every
|
|
46
|
+
* control at once — or, if we failed closed on it, denies everything at once. The cost is honest
|
|
47
|
+
* and worth naming: a malformed `enforce` rule is an enforcement gap, so `quarantined` is
|
|
48
|
+
* *reported* rather than merely counted.
|
|
49
|
+
*
|
|
50
|
+
* Glob clauses are `fnmatch`, never regex. There is no alternation: `"git (status|diff)*"` matches
|
|
51
|
+
* that literal text and nothing else. The list form is how you spell alternation, which is why the
|
|
52
|
+
* matcher needs no pattern language of its own.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const ALLOW = 'allow';
|
|
56
|
+
const DENY = 'deny';
|
|
57
|
+
const REQUIRE_APPROVAL = 'require_approval';
|
|
58
|
+
const ACTIONS = [ALLOW, DENY, REQUIRE_APPROVAL];
|
|
59
|
+
|
|
60
|
+
const RISK = { low: 0, medium: 1, high: 2 };
|
|
61
|
+
|
|
62
|
+
const CLAUSES = new Set(['kind', 'tool', 'tool_glob', 'target', 'target_glob', 'target_contains',
|
|
63
|
+
'command_glob', 'repos', 'branch_not', 'max_risk', 'field_equals']);
|
|
64
|
+
|
|
65
|
+
/** Distinct from `undefined`, because a subject may legitimately carry `undefined`. */
|
|
66
|
+
const MISSING = Symbol('missing');
|
|
67
|
+
const UNCACHED = Symbol('uncached');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Structural delimiters an `allow` rule's `*` may not cross. Chosen as the characters that change
|
|
71
|
+
* what a URL or path *means*: authority (`@`), path (`/`), query (`?`) and fragment (`#`).
|
|
72
|
+
*
|
|
73
|
+
* Space is deliberately not one — `command_glob: "git status*"` must keep matching
|
|
74
|
+
* `git status -s`, and a space does not reinterpret the rest of the string.
|
|
75
|
+
*/
|
|
76
|
+
const SEPARATORS = '/?#@';
|
|
77
|
+
const SEP_SPLIT = /([/?#@])/;
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
// glob
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `fnmatch.fnmatchcase`, which JavaScript has no equivalent of.
|
|
85
|
+
*
|
|
86
|
+
* Translated to a regular expression rather than hand-matched, because the translation is small and
|
|
87
|
+
* total: `*` is any run, `?` is one character, `[...]` is a class (with `!` as its negation, which
|
|
88
|
+
* is fnmatch's spelling of `^`), and every other character is a literal. Written out because
|
|
89
|
+
* reaching for a glob library here would put a third-party pattern engine inside a security
|
|
90
|
+
* control — the one place a transitive dependency is least welcome.
|
|
91
|
+
*/
|
|
92
|
+
function globToRegExp(pattern) {
|
|
93
|
+
let out = '';
|
|
94
|
+
let i = 0;
|
|
95
|
+
while (i < pattern.length) {
|
|
96
|
+
const c = pattern[i];
|
|
97
|
+
if (c === '*') { out += '[\\s\\S]*'; i += 1; continue; }
|
|
98
|
+
if (c === '?') { out += '[\\s\\S]'; i += 1; continue; }
|
|
99
|
+
if (c === '[') {
|
|
100
|
+
const close = pattern.indexOf(']', i + (pattern[i + 1] === '!' || pattern[i + 1] === ']' ? 2 : 1));
|
|
101
|
+
if (close === -1) { out += '\\['; i += 1; continue; } // unterminated class is a literal
|
|
102
|
+
let body = pattern.slice(i + 1, close);
|
|
103
|
+
if (body[0] === '!') body = '^' + body.slice(1);
|
|
104
|
+
// Escape a backslash inside the class so it cannot introduce an escape of its own.
|
|
105
|
+
out += '[' + body.replace(/\\/g, '\\\\') + ']';
|
|
106
|
+
i = close + 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
110
|
+
i += 1;
|
|
111
|
+
}
|
|
112
|
+
return new RegExp('^' + out + '$', 's');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const globCache = new Map();
|
|
116
|
+
function fnmatchcase(text, pattern) {
|
|
117
|
+
let re = globCache.get(pattern);
|
|
118
|
+
if (re === undefined) {
|
|
119
|
+
re = globToRegExp(pattern);
|
|
120
|
+
// Bounded, because patterns come from a signed envelope but the cache still must not grow
|
|
121
|
+
// without limit across many installs in one process.
|
|
122
|
+
if (globCache.size > 4096) globCache.clear();
|
|
123
|
+
globCache.set(pattern, re);
|
|
124
|
+
}
|
|
125
|
+
return re.test(text);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `fnmatch`, except `*` and `?` do not cross `/`, `?`, `#` or `@`.
|
|
130
|
+
*
|
|
131
|
+
* Implemented by splitting both sides on the delimiters and matching pairwise — the same thing as
|
|
132
|
+
* compiling a stricter wildcard, without hand-writing a glob-to-regex translator with different
|
|
133
|
+
* semantics inside a security control. A differing number of pieces is an immediate no, which is
|
|
134
|
+
* what stops `https://*.safe.com/*` matching `https://evil.com/a?z=.safe.com/b`: eleven pieces
|
|
135
|
+
* against seven.
|
|
136
|
+
*
|
|
137
|
+
* `**` anywhere in the pattern opts back into plain `fnmatch`. An author who wants a wildcard that
|
|
138
|
+
* crosses delimiters writes it in two characters and has said so on purpose, which is a different
|
|
139
|
+
* thing from getting one by accident.
|
|
140
|
+
*/
|
|
141
|
+
function globStrict(text, pattern) {
|
|
142
|
+
if (pattern.includes('**')) return fnmatchcase(text, pattern);
|
|
143
|
+
const pat = pattern.split(SEP_SPLIT);
|
|
144
|
+
const txt = String(text).split(SEP_SPLIT);
|
|
145
|
+
if (txt.length !== pat.length) return false;
|
|
146
|
+
for (let i = 0; i < txt.length; i += 1) {
|
|
147
|
+
// `split` with a capturing group alternates piece, delimiter, piece, … so odd indices are
|
|
148
|
+
// delimiters and must be identical rather than matched.
|
|
149
|
+
if (i % 2 === 1) { if (txt[i] !== pat[i]) return false; } else if (!fnmatchcase(txt[i], pat[i])) return false;
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
// normalisation
|
|
156
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* NFKC-normalise and case-fold. Applied only where matching *more* is the safe direction.
|
|
160
|
+
*
|
|
161
|
+
* `toLowerCase()` rather than a true Unicode case-fold, which JavaScript does not expose. The
|
|
162
|
+
* difference is confined to a handful of characters (fi, ß, ς) and lands on the permissive side of
|
|
163
|
+
* a *refusing* rule — the same direction the whole asymmetry moves in — so it is a narrower
|
|
164
|
+
* approximation than it looks. Named rather than left as an implicit "close enough".
|
|
165
|
+
*/
|
|
166
|
+
function fold(s) {
|
|
167
|
+
return String(s).normalize('NFKC').toLowerCase();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const foldCache = new Map();
|
|
171
|
+
function foldPattern(p) {
|
|
172
|
+
const key = String(p);
|
|
173
|
+
let hit = foldCache.get(key);
|
|
174
|
+
if (hit === undefined) {
|
|
175
|
+
hit = fold(key);
|
|
176
|
+
if (foldCache.size > 4096) foldCache.clear();
|
|
177
|
+
foldCache.set(key, hit);
|
|
178
|
+
}
|
|
179
|
+
return hit;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
// parsing
|
|
184
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
const EMPTY = Object.freeze({ rules: [], quarantined: [] });
|
|
187
|
+
|
|
188
|
+
/** True when this rule can actually stop something. */
|
|
189
|
+
function enforcing(rule) {
|
|
190
|
+
return Boolean(rule.enforce) && (rule.action === DENY || rule.action === REQUIRE_APPROVAL);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasEnforcing(ruleset) {
|
|
194
|
+
return ruleset.rules.some(enforcing);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseOne(raw) {
|
|
198
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [null, 'rule is not an object'];
|
|
199
|
+
|
|
200
|
+
const id = raw.id;
|
|
201
|
+
if (typeof id !== 'string' || !id.trim()) return [null, 'rule has no id'];
|
|
202
|
+
|
|
203
|
+
const action = raw.action;
|
|
204
|
+
if (!ACTIONS.includes(action)) {
|
|
205
|
+
return [null, 'rule ' + JSON.stringify(id) + ' unknown action ' + JSON.stringify(action)];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const match = raw.match === undefined ? {} : raw.match;
|
|
209
|
+
if (!match || typeof match !== 'object' || Array.isArray(match)) {
|
|
210
|
+
return [null, 'rule ' + JSON.stringify(id) + ' non-object match'];
|
|
211
|
+
}
|
|
212
|
+
const unknown = Object.keys(match).filter((k) => !CLAUSES.has(k)).sort();
|
|
213
|
+
if (unknown.length) {
|
|
214
|
+
return [null, 'rule ' + JSON.stringify(id) + ' unknown clauses ' + JSON.stringify(unknown)];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const mr = match.max_risk;
|
|
218
|
+
if (mr !== undefined && mr !== null && !(mr in RISK)) {
|
|
219
|
+
return [null, 'rule ' + JSON.stringify(id) + ' unknown max_risk ' + JSON.stringify(mr)];
|
|
220
|
+
}
|
|
221
|
+
const fe = match.field_equals;
|
|
222
|
+
if (fe !== undefined && fe !== null && (typeof fe !== 'object' || Array.isArray(fe))) {
|
|
223
|
+
return [null, 'rule ' + JSON.stringify(id) + ' non-object field_equals'];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// `=== true` rather than truthy: `"enforce": "no"` is a string, and a string is truthy. An
|
|
227
|
+
// envelope that meant to disarm a rule and spelled it wrongly must not arm it instead.
|
|
228
|
+
const enforce = raw.enforce === true;
|
|
229
|
+
|
|
230
|
+
const approval = raw.approval === undefined || raw.approval === null ? {} : raw.approval;
|
|
231
|
+
if (typeof approval !== 'object' || Array.isArray(approval)) {
|
|
232
|
+
return [null, 'rule ' + JSON.stringify(id) + ' non-object approval block'];
|
|
233
|
+
}
|
|
234
|
+
const timeout = approval.timeout_s;
|
|
235
|
+
if (timeout !== undefined && timeout !== null
|
|
236
|
+
&& (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0)) {
|
|
237
|
+
return [null, 'rule ' + JSON.stringify(id) + ' non-positive approval timeout'];
|
|
238
|
+
}
|
|
239
|
+
let onTimeout = approval.on_timeout;
|
|
240
|
+
if (onTimeout === undefined || onTimeout === null) {
|
|
241
|
+
// An approval gate that allows when nobody answers is not a gate; an *advisory* one that
|
|
242
|
+
// denied when nobody answered blocks calls the customer never asked us to block. So the
|
|
243
|
+
// default follows the marking rather than being a constant.
|
|
244
|
+
onTimeout = enforce ? DENY : ALLOW;
|
|
245
|
+
}
|
|
246
|
+
if (onTimeout !== ALLOW && onTimeout !== DENY) {
|
|
247
|
+
return [null, 'rule ' + JSON.stringify(id) + ' unknown on_timeout ' + JSON.stringify(onTimeout)];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return [Object.freeze({
|
|
251
|
+
id,
|
|
252
|
+
action,
|
|
253
|
+
enforce,
|
|
254
|
+
reason: typeof raw.reason === 'string' ? raw.reason : '',
|
|
255
|
+
match,
|
|
256
|
+
approvalTimeoutS: typeof timeout === 'number' ? timeout : null,
|
|
257
|
+
onTimeout,
|
|
258
|
+
}), null];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Build a rule set. Never throws; anything unusable becomes a quarantine entry. */
|
|
262
|
+
function parse(rawRules, maxRules) {
|
|
263
|
+
const cap = maxRules === undefined ? 1000 : maxRules;
|
|
264
|
+
if (!Array.isArray(rawRules)) return EMPTY;
|
|
265
|
+
const good = [];
|
|
266
|
+
const bad = [];
|
|
267
|
+
const slice = rawRules.slice(0, cap);
|
|
268
|
+
for (let i = 0; i < slice.length; i += 1) {
|
|
269
|
+
let rule = null;
|
|
270
|
+
let why = null;
|
|
271
|
+
try {
|
|
272
|
+
[rule, why] = parseOne(slice[i]);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
rule = null;
|
|
275
|
+
why = (err && err.name ? err.name : 'Error') + ': ' + (err && err.message);
|
|
276
|
+
}
|
|
277
|
+
if (rule === null) bad.push([i, why || 'unusable']);
|
|
278
|
+
else good.push(rule);
|
|
279
|
+
}
|
|
280
|
+
return Object.freeze({ rules: good, quarantined: bad });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
// matching
|
|
285
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
function get(subject, name) {
|
|
288
|
+
const v = subject === null || subject === undefined ? undefined : subject[name];
|
|
289
|
+
if (v === undefined || v === null || v === '') return MISSING;
|
|
290
|
+
return v;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function asList(v) {
|
|
294
|
+
return Array.isArray(v) ? v : [v];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A subject field as a comparison string, normalised unless the rule is an `allow`.
|
|
299
|
+
*
|
|
300
|
+
* The cache is keyed by field name and lives for one decision, so a 50-rule set that reads `target`
|
|
301
|
+
* in every rule normalises it once rather than fifty times. This is on the request path under a
|
|
302
|
+
* latency budget, so that matters.
|
|
303
|
+
*/
|
|
304
|
+
function field(subject, name, allowing, folded) {
|
|
305
|
+
if (allowing) {
|
|
306
|
+
const v = get(subject, name);
|
|
307
|
+
return v === MISSING ? MISSING : String(v);
|
|
308
|
+
}
|
|
309
|
+
let hit = folded[name];
|
|
310
|
+
if (hit === undefined) hit = UNCACHED;
|
|
311
|
+
if (hit === UNCACHED) {
|
|
312
|
+
const v = get(subject, name);
|
|
313
|
+
hit = v === MISSING ? MISSING : fold(String(v));
|
|
314
|
+
folded[name] = hit;
|
|
315
|
+
}
|
|
316
|
+
return hit;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const pat = (x, allowing) => (allowing ? String(x) : foldPattern(String(x)));
|
|
320
|
+
|
|
321
|
+
function exact(got, want, allowing) {
|
|
322
|
+
if (got === MISSING) return !allowing;
|
|
323
|
+
return asList(want).some((x) => pat(x, allowing) === got);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function glob(got, want, allowing) {
|
|
327
|
+
if (got === MISSING) return !allowing;
|
|
328
|
+
if (allowing) return asList(want).some((p) => globStrict(got, String(p)));
|
|
329
|
+
return asList(want).some((p) => fnmatchcase(got, pat(p, false)));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function contains(got, want, allowing) {
|
|
333
|
+
if (got === MISSING) return !allowing;
|
|
334
|
+
return asList(want).some((p) => got.includes(pat(p, allowing)));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function clauseOk(clause, want, subject, allowing, folded) {
|
|
338
|
+
switch (clause) {
|
|
339
|
+
case 'kind': return exact(field(subject, 'kind', allowing, folded), want, allowing);
|
|
340
|
+
case 'tool': return exact(field(subject, 'tool', allowing, folded), want, allowing);
|
|
341
|
+
case 'target': return exact(field(subject, 'target', allowing, folded), want, allowing);
|
|
342
|
+
case 'tool_glob': return glob(field(subject, 'tool', allowing, folded), want, allowing);
|
|
343
|
+
case 'target_glob': return glob(field(subject, 'target', allowing, folded), want, allowing);
|
|
344
|
+
case 'command_glob': return glob(field(subject, 'command', allowing, folded), want, allowing);
|
|
345
|
+
case 'repos': return glob(field(subject, 'repo', allowing, folded), want, allowing);
|
|
346
|
+
case 'target_contains':
|
|
347
|
+
return contains(field(subject, 'target', allowing, folded), want, allowing);
|
|
348
|
+
|
|
349
|
+
case 'branch_not': {
|
|
350
|
+
// The polarity inverts here, because the clause is negative. `branch_not: ["main"]` in a
|
|
351
|
+
// *deny* rule exempts main, so folding would *widen the exemption* and weaken the deny; in an
|
|
352
|
+
// *allow* rule it restricts the allow, so folding narrows it. The safe direction is therefore
|
|
353
|
+
// the opposite of everywhere else: normalise for `allow`, not for `deny`.
|
|
354
|
+
const branch = field(subject, 'branch', !allowing, folded);
|
|
355
|
+
// A subject with no branch does not satisfy `branch_not`. "It isn't main because I couldn't
|
|
356
|
+
// tell" is the smuggling route.
|
|
357
|
+
if (branch === MISSING) return !allowing;
|
|
358
|
+
return !asList(want).some((x) => pat(x, !allowing) === branch);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
case 'max_risk': {
|
|
362
|
+
const risk = field(subject, 'risk', allowing, folded);
|
|
363
|
+
if (risk === MISSING) return !allowing; // no risk stated == worst case
|
|
364
|
+
// An unknown risk is 99, i.e. worse than anything named. Folding only means `"HIGH"` is
|
|
365
|
+
// recognised as high, which makes a `deny` with `max_risk: "high"` fire where it previously
|
|
366
|
+
// fell through, and leaves an `allow` case-sensitive as above.
|
|
367
|
+
const got = RISK[risk] === undefined ? 99 : RISK[risk];
|
|
368
|
+
const limit = RISK[String(want)] === undefined ? -1 : RISK[String(want)];
|
|
369
|
+
return got <= limit;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
case 'field_equals': {
|
|
373
|
+
for (const [name, expected] of Object.entries(want || {})) {
|
|
374
|
+
const got = field(subject, name, allowing, folded);
|
|
375
|
+
if (got === MISSING) {
|
|
376
|
+
if (allowing) return false;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (!asList(expected).some((x) => pat(x, allowing) === got)) return false;
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
default: return false; // unreachable: parse rejects unknown clauses
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* True iff `rule` covers `subject`. Clauses are ANDed; a list matches if any entry does.
|
|
390
|
+
*
|
|
391
|
+
* `folded` is a per-decision scratch object so a subject value is NFKC-normalised once rather than
|
|
392
|
+
* once per rule. Passing nothing is correct and simply means no sharing.
|
|
393
|
+
*/
|
|
394
|
+
function matches(rule, subject, folded) {
|
|
395
|
+
const allowing = rule.action === ALLOW;
|
|
396
|
+
const scratch = folded || {};
|
|
397
|
+
const m = rule.match;
|
|
398
|
+
|
|
399
|
+
if (!m || Object.keys(m).length === 0) {
|
|
400
|
+
// A rule with no clauses is unconditional. For a deny that is a legitimate thing to ship —
|
|
401
|
+
// `{action: 'deny', enforce: true}` is the kill switch, and the control plane is entitled to
|
|
402
|
+
// send it. For an *allow* it is almost always a mistake (a match block that failed to
|
|
403
|
+
// serialise, a template that rendered empty), and the mistake authorises everything. So
|
|
404
|
+
// unconditional means "everything" in the refusing direction only.
|
|
405
|
+
return rule.action !== ALLOW;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const [clause, want] of Object.entries(m)) {
|
|
409
|
+
if (!clauseOk(clause, want, subject, allowing, scratch)) return false;
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* First matching rule wins.
|
|
416
|
+
*
|
|
417
|
+
* `deadline` is polled between rules — cooperative rather than pre-emptive, because JavaScript
|
|
418
|
+
* cannot interrupt a running frame any more than Python can. The honest statement of the guarantee
|
|
419
|
+
* is that it is bounded per-rule, not per-decision.
|
|
420
|
+
*
|
|
421
|
+
* `onError` is called for a matcher that throws, which is a malformed rule that got past parsing.
|
|
422
|
+
* The rule is skipped and evaluation continues: one bad rule must not stop the rules after it from
|
|
423
|
+
* applying, which is the same argument as quarantining at parse time.
|
|
424
|
+
*/
|
|
425
|
+
function firstMatch(ruleset, subject, deadline, onError) {
|
|
426
|
+
const folded = {};
|
|
427
|
+
for (const rule of ruleset.rules) {
|
|
428
|
+
if (deadline && deadline()) return null;
|
|
429
|
+
try {
|
|
430
|
+
if (matches(rule, subject, folded)) return rule;
|
|
431
|
+
} catch (err) {
|
|
432
|
+
if (onError) onError(rule, err);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
module.exports = {
|
|
439
|
+
ALLOW, DENY, REQUIRE_APPROVAL, ACTIONS, CLAUSES, RISK, EMPTY, SEPARATORS, MISSING,
|
|
440
|
+
parse, parseOne, matches, firstMatch, enforcing, hasEnforcing,
|
|
441
|
+
fold, foldPattern, globStrict, fnmatchcase, globToRegExp,
|
|
442
|
+
};
|