@allanoricil/nrg-sentinel 1.0.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.md +49 -0
- package/README.md +748 -0
- package/bin/node-red.js +8 -0
- package/flow-diff.js +7 -0
- package/package.json +38 -0
- package/plugin.html +1184 -0
- package/plugin.js +7 -0
- package/preload.js +7 -0
- package/review-ui.html +795 -0
- package/safe-deployment-queue.js +7 -0
- package/service-worker.js +7 -0
- package/sw-register.js +7 -0
- package/vendor/flow-renderer.min.js +1 -0
- package/vendor/vue.prod.js +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img alt="nrg-sentinel-icon" src="https://gist.githubusercontent.com/AllanOricil/244c22dad889ed47ef6530e5bb605536/raw/b0b02ada070c2bc2d4970cf186866917a3af143b/nrg-sentinel-icon.svg" style="width: 200px"/>
|
|
3
|
+
</p>
|
|
4
|
+
<br/>
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/AllanOricil/nrg-sentinel-public/actions/workflows/release.yml"><img src="https://github.com/AllanOricil/nrg-sentinel-public/actions/workflows/release.yml/badge.svg" alt="Release"></a>
|
|
7
|
+
<a href="https://github.com/AllanOricil/nrg-sentinel-public/actions/workflows/docker.yml"><img src="https://github.com/AllanOricil/nrg-sentinel-public/actions/workflows/docker.yml/badge.svg" alt="Docker"></a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
# NRG Sentinel
|
|
11
|
+
|
|
12
|
+
A security layer for Node-RED that detects and blocks common attack vectors at runtime without modifying the Node-RED core.
|
|
13
|
+
|
|
14
|
+
## E2E Test Results
|
|
15
|
+
|
|
16
|
+
The table below is updated automatically after each CI run on `main`.
|
|
17
|
+
|
|
18
|
+
<!-- DEMO-TEST-RESULTS:START -->
|
|
19
|
+
| # | Demo | Result | Node-RED |
|
|
20
|
+
|---|------|:------:|:--------:|
|
|
21
|
+
| 01 | Monkey Patching | ✅ | `4.1.7` |
|
|
22
|
+
| 02 | Hook Injection | ✅ | `4.1.7` |
|
|
23
|
+
| 03 | Credential Theft | ✅ | `4.1.7` |
|
|
24
|
+
| 04 | Wire Manipulation | ✅ | `4.1.7` |
|
|
25
|
+
| 05 | Direct Receive Injection | ✅ | `4.1.7` |
|
|
26
|
+
| 06 | Express Middleware | ✅ | `4.1.7` |
|
|
27
|
+
| 07 | EventEmitter Hijack | ✅ | `4.1.7` |
|
|
28
|
+
| 08 | Node Enumeration | ✅ | `4.1.7` |
|
|
29
|
+
| 09 | Prototype Pollution | ✅ | `4.1.7` |
|
|
30
|
+
| 10 | Flow File Tampering | ✅ | `4.1.7` |
|
|
31
|
+
| 11 | Message Provenance | ✅ | `4.1.7` |
|
|
32
|
+
| 12 | Settings.js Tampering | ✅ | `4.1.7` |
|
|
33
|
+
| 13 | Sentinel Source Tampering | ✅ | `4.1.7` |
|
|
34
|
+
| 14 | Express Route Backdoor | ✅ | `4.1.7` |
|
|
35
|
+
| 15 | Config Node Z-Forgery | ✅ | `4.1.7` |
|
|
36
|
+
| 16 | Symbol Property Bypass | ✅ | `4.1.7` |
|
|
37
|
+
| 17 | EventEmitter Enumeration | ✅ | `4.1.7` |
|
|
38
|
+
| 18 | Deep Stack Bypass | ✅ | `4.1.7` |
|
|
39
|
+
| 19 | HTTP Route Deletion | ✅ | `4.1.7` |
|
|
40
|
+
| 20 | Child Process Exec | ✅ | `4.1.7` |
|
|
41
|
+
| 22 | FS Read | ✅ | `4.1.7` |
|
|
42
|
+
| 23 | Process Env Exfiltration | ✅ | `4.1.7` |
|
|
43
|
+
| 24 | Process Exit DoS | ✅ | `4.1.7` |
|
|
44
|
+
| 25 | VM Sandbox Escape | ✅ | `4.1.7` |
|
|
45
|
+
| 26 | Worker Thread Escape | ✅ | `4.1.7` |
|
|
46
|
+
| 27 | Network Socket Exfiltration | ✅ | `4.1.7` |
|
|
47
|
+
| 28 | Registry Type Hijack | ✅ | `4.1.7` |
|
|
48
|
+
| 29 | Settings Mutation | ✅ | `4.1.7` |
|
|
49
|
+
| 30 | Comms Publish Spoofing | ✅ | `4.1.7` |
|
|
50
|
+
| 31 | Context Permissions | ✅ | `4.1.7` |
|
|
51
|
+
| 32 | Flows Inject | ✅ | `4.1.7` |
|
|
52
|
+
| 33 | Node Event Hijack | ✅ | `4.1.7` |
|
|
53
|
+
_Last updated: 2026-03-17T08:03:19Z_
|
|
54
|
+
<!-- DEMO-TEST-RESULTS:END -->
|
|
55
|
+
|
|
56
|
+
## Demos
|
|
57
|
+
|
|
58
|
+
Each demo is a self-contained scenario that shows an attack against Node-RED and how Sentinel blocks it.
|
|
59
|
+
|
|
60
|
+
| # | Demo | Attack vector |
|
|
61
|
+
| --- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
|
62
|
+
| 01 | Monkey Patching | Overwrites Node-RED core functions at runtime |
|
|
63
|
+
| 02 | Hook Injection | Registers malicious `onSend`/`onReceive` hooks |
|
|
64
|
+
| 03 | Credential Theft | Reads decrypted credentials from live node instances |
|
|
65
|
+
| 04 | Wire Manipulation | Rewires flow connections to exfiltrate data |
|
|
66
|
+
| 05 | Direct Receive Injection | Bypasses auth chain via `node.receive()` |
|
|
67
|
+
| 06 | Express Middleware | Installs rogue HTTP middleware on the admin API |
|
|
68
|
+
| 07 | EventEmitter Hijack | Intercepts internal Node-RED events |
|
|
69
|
+
| 08 | Node Enumeration | Maps every node in the runtime via `eachNode()` |
|
|
70
|
+
| 09 | Prototype Pollution | Pollutes `Object.prototype` to affect all objects |
|
|
71
|
+
| 10 | Flow File Tampering | Modifies the flows file on disk |
|
|
72
|
+
| 11 | Message Provenance | Detects and blocks injected messages via HMAC tagging |
|
|
73
|
+
| 12 | Settings.js Tampering | Modifies settings.js at runtime to inject capability grants |
|
|
74
|
+
| 13 | Sentinel Source Tampering | Patches Sentinel's preload.js on disk to disable protection |
|
|
75
|
+
| 14 | Express Route Backdoor | Registers a hidden admin API route via `httpAdmin.get()` |
|
|
76
|
+
| 15 | Config Node Z-Forgery | Fakes config-node identity to bypass credential access rules |
|
|
77
|
+
| 16 | Symbol Property Bypass | Uses Symbol-keyed properties to evade proxy guard interception |
|
|
78
|
+
| 17 | EventEmitter Enumeration | Enumerates all `RED.events` listeners to map internal runtime wiring |
|
|
79
|
+
| 18 | Deep Stack Bypass | Chains anonymous wrappers to push the malicious frame outside the guard window |
|
|
80
|
+
| 19 | HTTP Route Deletion | Deletes existing Express routes to disable authentication endpoints |
|
|
81
|
+
| 20 | Child Process Exec | Spawns a shell command via `child_process` to execute arbitrary OS commands |
|
|
82
|
+
| 22 | FS Read | Reads `settings.js` via `require('fs')` to extract the credential secret |
|
|
83
|
+
| 23 | Process Env Exfiltration | Reads `process.env` to harvest injected secrets and API keys |
|
|
84
|
+
| 24 | Process Exit DoS | Calls `process.exit()` from a message handler to kill the runtime |
|
|
85
|
+
| 25 | VM Sandbox Escape | Uses `require('vm')` to run code outside Sentinel's `Module._load` hooks |
|
|
86
|
+
| 26 | Worker Thread Escape | Spawns a worker thread whose module loader is invisible to Sentinel |
|
|
87
|
+
| 27 | Network Socket Exfiltration | Creates a raw TCP socket to bypass the HTTP URL allowlist |
|
|
88
|
+
| 28 | Registry Type Hijack | Calls `registerType('inject', ...)` to silently replace a built-in node type |
|
|
89
|
+
| 29 | Settings Mutation | Reads or writes `RED.settings` to extract the credential secret or add backdoors |
|
|
90
|
+
| 30 | Comms Publish Spoofing | Pushes fake notifications to the editor via `RED.comms.publish()` |
|
|
91
|
+
| 31 | Context Permissions | Reads or writes another node's context store without a grant |
|
|
92
|
+
| 32 | Flows Inject | Injects a malicious node into the running flow via the flows API |
|
|
93
|
+
| 33 | Node Event Hijack | Spies on or silences another node's input handler via EventEmitter APIs |
|
|
94
|
+
|
|
95
|
+
## Capability grants
|
|
96
|
+
|
|
97
|
+
By default Sentinel blocks every privileged operation for every third-party package. A package that needs a capability must be explicitly granted it in `settings.js`.
|
|
98
|
+
|
|
99
|
+
For the complete capability reference — every capability string, what it gates, shorthand expansions, and known gaps — see **docs/capability-design.md**.
|
|
100
|
+
|
|
101
|
+
### Adding a grant
|
|
102
|
+
|
|
103
|
+
Grants live in the `sentinel.allow` map inside `settings.js`. Each key is an **npm package name** exactly as it appears in `node_modules/`; the value is an array of capability strings.
|
|
104
|
+
|
|
105
|
+
#### Node-RED core nodes do not need grants
|
|
106
|
+
|
|
107
|
+
Sentinel only applies capability checks to packages loaded from the Node-RED **userDir** (`{userDir}/node_modules/` or `{userDir}/nodes/`). Node-RED's own built-in nodes (`inject`, `debug`, `function`, `http request`, etc.) are part of the Node-RED installation itself and live outside the userDir, so Sentinel never gates them. You only need to add grants for **third-party packages** that users install into their userDir.
|
|
108
|
+
|
|
109
|
+
#### `registry:register` — required for every node package
|
|
110
|
+
|
|
111
|
+
Every node package must be granted `registry:register` so Sentinel allows it to call `RED.nodes.registerType()` at startup. Without this grant, Sentinel blocks the call, the node type is never registered, and Node-RED logs _"Waiting for missing types"_ indefinitely.
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
// settings.js — minimal grant for a node package that needs no other privileges
|
|
115
|
+
module.exports = {
|
|
116
|
+
sentinel: {
|
|
117
|
+
allow: {
|
|
118
|
+
"my-custom-node": ["registry:register"],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### Common grants
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
// settings.js
|
|
128
|
+
module.exports = {
|
|
129
|
+
sentinel: {
|
|
130
|
+
allow: {
|
|
131
|
+
// A node that reads its own credentials (this.credentials) directly.
|
|
132
|
+
// See "Credential access patterns" below for config-node and cross-node cases.
|
|
133
|
+
"node-red-contrib-influxdb": ["registry:register", "node:credentials:read"],
|
|
134
|
+
|
|
135
|
+
// A flow-auditing plugin that needs to inspect the runtime topology.
|
|
136
|
+
"node-red-contrib-flow-auditor": [
|
|
137
|
+
"registry:register",
|
|
138
|
+
"node:list", // RED.nodes.eachNode()
|
|
139
|
+
"node:wires:read", // read node.wires (output topology)
|
|
140
|
+
"flows:read", // RED.runtime.flows.getFlows() / getFlow(id)
|
|
141
|
+
],
|
|
142
|
+
|
|
143
|
+
// A tracing / APM plugin that hooks the message pipeline.
|
|
144
|
+
// hooks:on-send fires before routing; hooks:post-deliver fires after delivery.
|
|
145
|
+
"node-red-contrib-tracer": ["registry:register", "hooks:on-send", "hooks:post-deliver"],
|
|
146
|
+
|
|
147
|
+
// A node that registers its own admin UI routes.
|
|
148
|
+
// http:admin covers httpAdmin; http:node covers httpNode.
|
|
149
|
+
"node-red-contrib-dashboard": ["registry:register", "http:admin", "http:node"],
|
|
150
|
+
|
|
151
|
+
// A node that genuinely needs to run OS commands.
|
|
152
|
+
"node-red-contrib-exec": ["registry:register", "process:exec"],
|
|
153
|
+
|
|
154
|
+
// A node that reads files from disk (e.g. a CSV reader).
|
|
155
|
+
"node-red-contrib-file-in": ["registry:register", "fs:read"],
|
|
156
|
+
|
|
157
|
+
// A node that makes outbound HTTP calls.
|
|
158
|
+
// network:http covers http.request/https.request.
|
|
159
|
+
// Add specific URLs to sentinel.networkPolicy.allowlist to restrict further.
|
|
160
|
+
"node-red-contrib-http-request": ["registry:register", "network:http"],
|
|
161
|
+
|
|
162
|
+
// A plugin (no node types) that listens to runtime events.
|
|
163
|
+
// Plugins are registered via the node-red.plugins key in package.json
|
|
164
|
+
// and do not call registerType — no registry:register needed.
|
|
165
|
+
"node-red-contrib-audit-logger": ["events:listen"],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Sentinel identifies the calling package at runtime by walking the call stack and extracting the `node_modules/<package>` segment from the nearest frame that does not belong to Node-RED or Sentinel itself. The match is against the npm package name exactly as it appears on disk.
|
|
172
|
+
|
|
173
|
+
### Credential access patterns
|
|
174
|
+
|
|
175
|
+
The capability needed (if any) depends on which node owns the credentials being read.
|
|
176
|
+
|
|
177
|
+
#### Reading a node's own credentials
|
|
178
|
+
|
|
179
|
+
A node reading `this.credentials` in its own constructor or message handler needs `node:credentials:read` granted to its own package:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
// node-red-contrib-my-api/index.js
|
|
183
|
+
module.exports = function (RED) {
|
|
184
|
+
function MyApiNode(config) {
|
|
185
|
+
RED.nodes.createNode(this, config);
|
|
186
|
+
var apiKey = this.credentials.apiKey; // guarded: needs node:credentials:read
|
|
187
|
+
this.on("input", function (msg) {
|
|
188
|
+
// use apiKey ...
|
|
189
|
+
this.send(msg);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
RED.nodes.registerType("my-api", MyApiNode);
|
|
193
|
+
};
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
// settings.js
|
|
198
|
+
sentinel: {
|
|
199
|
+
allow: {
|
|
200
|
+
"node-red-contrib-my-api": ["registry:register", "node:credentials:read"],
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Why the node needs an explicit grant to read its own credentials:** if any node could always read `this.credentials` without a grant, a compromised or malicious package that registers a node type would automatically get access to whatever credentials the operator stored for it — no configuration signal, no audit trail, nothing for the operator to review or approve. Requiring the explicit grant means the operator has consciously decided "I trust this package to handle credentials." It also makes the intent visible: you can scan `settings.js` and immediately see which packages touch credential data.
|
|
206
|
+
|
|
207
|
+
If credentials were silently self-readable, `node:credentials:read` would only be needed for cross-node access — but then the presence or absence of the grant would tell you nothing about whether a package handles its own secrets, removing half its value as an audit signal.
|
|
208
|
+
|
|
209
|
+
#### Reading credentials from a config node referenced in its config
|
|
210
|
+
|
|
211
|
+
Config nodes exist specifically to hold and share credentials with the consumer nodes that reference them. Sentinel opens `node:credentials:read` for config nodes by default — no capability grant is needed for either the config node package or the consumer.
|
|
212
|
+
|
|
213
|
+
The idiomatic pattern is for the config node to read its own credentials and expose them as plain properties:
|
|
214
|
+
|
|
215
|
+
```js
|
|
216
|
+
// node-red-contrib-influxdb/index.js
|
|
217
|
+
module.exports = function (RED) {
|
|
218
|
+
function InfluxConfigNode(config) {
|
|
219
|
+
RED.nodes.createNode(this, config);
|
|
220
|
+
// Config node reads its own credentials — allowed by default (no grant needed).
|
|
221
|
+
this.token = this.credentials.token;
|
|
222
|
+
this.host = config.host;
|
|
223
|
+
}
|
|
224
|
+
RED.nodes.registerType("influxdb-config", InfluxConfigNode);
|
|
225
|
+
|
|
226
|
+
function InfluxWriteNode(config) {
|
|
227
|
+
RED.nodes.createNode(this, config);
|
|
228
|
+
// Consumer accesses the config node's plain property — no credential cap needed.
|
|
229
|
+
var configNode = RED.nodes.getNode(config.configId);
|
|
230
|
+
this.on("input", function (msg) {
|
|
231
|
+
writeToInflux(configNode.host, configNode.token, msg.payload);
|
|
232
|
+
this.send(msg);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
RED.nodes.registerType("influxdb-write", InfluxWriteNode);
|
|
236
|
+
};
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
// settings.js — no node:credentials:read needed for either package
|
|
241
|
+
sentinel: {
|
|
242
|
+
allow: {
|
|
243
|
+
"node-red-contrib-influxdb": ["registry:register"],
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
If a consumer accesses `configNode.credentials.token` directly instead of a plain property, it also works without any grant — the config node default rule still applies because the proxy belongs to a config node.
|
|
249
|
+
|
|
250
|
+
**Why config nodes are open by default:** if consumers were required to have `node:credentials:read` to access a config node, every single package that uses any config node — influxdb-write, mqtt-out, http-request, anything that reads a username or token from a config node — would need the grant. In a real installation with a dozen contrib packages, `node:credentials:read` would appear in nearly every entry in `settings.js`, and it would cease to be a meaningful security signal. You would no longer be able to tell at a glance which packages are genuinely handling raw secrets versus simply using a config node the way Node-RED was designed.
|
|
251
|
+
|
|
252
|
+
The config node pattern is a declared contract between Node-RED packages: the config node's author published it specifically to share its credentials, and the consumer's author explicitly wired to it in their node definition. An operator who installs both has implicitly accepted that relationship. Making it require an extra grant would add friction to a legitimate, universal pattern without adding meaningful security. Operators who want to restrict which packages can read a specific config node's credentials can override this default via `.sentinel-grants.json` in the userDir — see the end of this section.
|
|
253
|
+
|
|
254
|
+
#### Reading credentials from a node that is not a config node
|
|
255
|
+
|
|
256
|
+
If a node reads `.credentials` from an arbitrary non-config node (one it does not reference via its own config), the **target node's package** must have `node:credentials:read` in its grant list. Sentinel captures the owning package at `createNode` time and uses it for every subsequent access through that proxy:
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
// node-red-contrib-reader/index.js — wants to read credentials from node-red-contrib-target
|
|
260
|
+
module.exports = function (RED) {
|
|
261
|
+
function ReaderNode(config) {
|
|
262
|
+
RED.nodes.createNode(this, config);
|
|
263
|
+
this.on("input", function (msg) {
|
|
264
|
+
var target = RED.nodes.getNode(config.targetId);
|
|
265
|
+
// The proxy for `target` was created with node-red-contrib-target as the
|
|
266
|
+
// owning package. Sentinel checks that package's grants, not this one's.
|
|
267
|
+
var secret = target.credentials.secret;
|
|
268
|
+
this.send(msg);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
RED.nodes.registerType("reader", ReaderNode);
|
|
272
|
+
};
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
```js
|
|
276
|
+
// settings.js — the TARGET package needs node:credentials:read, not the reader
|
|
277
|
+
sentinel: {
|
|
278
|
+
allow: {
|
|
279
|
+
"node-red-contrib-reader": ["registry:register"],
|
|
280
|
+
"node-red-contrib-target": ["registry:register", "node:credentials:read"],
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Why the target's package is checked, not the accessor's:** if it were the accessor's package that needed the grant, then any package granted `node:credentials:read` could call `RED.nodes.eachNode()` (with `node:list`) and iterate every node in the runtime, reading credentials from all of them in one sweep — a complete credential harvest with a single grant. The target-based check prevents that: having `node:credentials:read` only gives a package access to credentials stored in its _own_ nodes. It cannot reach into another package's nodes unless that package also has the grant, which the operator would have to explicitly add.
|
|
286
|
+
|
|
287
|
+
If it were the accessor's grant that mattered, `node:credentials:read` would effectively mean "read any node's credentials in the entire runtime." Instead it means "this package's nodes are authorized to handle credentials," which is a much narrower and more auditable claim.
|
|
288
|
+
|
|
289
|
+
Sentinel also looks for a `.sentinel-grants.json` file in the **userDir** (next to `settings.js`). This file is the backing store for the **Sentinel editor panel** — the Node-RED UI exposes admin API routes that read and write it directly, so operators can manage target permissions through the browser without touching `settings.js`.
|
|
290
|
+
|
|
291
|
+
This separation is intentional. In hardened deployments `settings.js` is mounted read-only (the Docker section mounts it `:ro`) so it cannot be modified at runtime. `.sentinel-grants.json` lives in the writable userDir (`/data` in Docker), giving the UI panel a place to persist changes. The two files have different ownership: `settings.js` is managed by deployment tooling; `.sentinel-grants.json` is managed by the UI.
|
|
292
|
+
|
|
293
|
+
The file grants access keyed on the **target node's type** rather than the caller's package name:
|
|
294
|
+
|
|
295
|
+
```json
|
|
296
|
+
{
|
|
297
|
+
"target-node-type": {
|
|
298
|
+
"node:credentials:read": ["node-red-contrib-reader"]
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
This is purely additive: if the caller is in the list, access is granted immediately. If it is not, Sentinel falls through to `settings.js` as normal — the file cannot block anything that `settings.js` already allows.
|
|
304
|
+
|
|
305
|
+
The one place it does restrict behaviour is the config node default. Normally any caller can read a config node's credentials without any grant (see above). If you add a `node:credentials:read` entry for a config node type in `.sentinel-grants.json`, that default is suppressed and access is limited to the listed callers (plus whatever `settings.js` grants):
|
|
306
|
+
|
|
307
|
+
```json
|
|
308
|
+
{
|
|
309
|
+
"my-config-node": {
|
|
310
|
+
"node:credentials:read": ["node-red-contrib-trusted-consumer"]
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
With this in place, `node-red-contrib-trusted-consumer` can read `my-config-node` credentials via the permissions file, and any package that has `node:credentials:read` in `settings.js` can still read them too. Packages with neither are blocked, even though `my-config-node` is a config node.
|
|
316
|
+
|
|
317
|
+
### Grants are per package, not per node type
|
|
318
|
+
|
|
319
|
+
A single npm package can register many node types, but all of them share the same package name in the call stack. Sentinel cannot distinguish `my-package/nodes/foo.js` from `my-package/nodes/bar.js` at the frame level — both resolve to `my-package`. This is intentional: the **package** is the unit you install, audit, and sign off on.
|
|
320
|
+
|
|
321
|
+
### Fine-grained control with scoped child packages
|
|
322
|
+
|
|
323
|
+
If you need different capability levels for different node types, publish each trust boundary as its own scoped package and group them under a parent that users install as a single dependency.
|
|
324
|
+
|
|
325
|
+
**Parent package** — a dependency aggregator with no node code of its own:
|
|
326
|
+
|
|
327
|
+
```json
|
|
328
|
+
{
|
|
329
|
+
"name": "@my-company/nodes",
|
|
330
|
+
"version": "1.0.0",
|
|
331
|
+
"dependencies": {
|
|
332
|
+
"@my-company/node-data-formatter": "^1.0.0",
|
|
333
|
+
"@my-company/node-mqtt-enricher": "^1.0.0",
|
|
334
|
+
"@my-company/node-flow-auditor": "^1.0.0"
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Child packages** — each has its own `node-red` field and npm identity:
|
|
340
|
+
|
|
341
|
+
```json
|
|
342
|
+
{
|
|
343
|
+
"name": "@my-company/node-mqtt-enricher",
|
|
344
|
+
"version": "1.0.0",
|
|
345
|
+
"node-red": { "nodes": { "mqtt-enricher": "index.js" } }
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
When a user runs `npm install @my-company/nodes`, npm (v7+) hoists the children to the top-level `node_modules/`. Node-RED discovers them directly because each has its own `node-red` field. Sentinel sees each child's package name independently, so grants can be applied at exactly the right granularity:
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
sentinel: {
|
|
353
|
+
allow: {
|
|
354
|
+
// formatter needs no privileged access — registry:register is enough
|
|
355
|
+
"@my-company/node-data-formatter": ["registry:register"],
|
|
356
|
+
// enricher reads credentials from a config node
|
|
357
|
+
"@my-company/node-mqtt-enricher": ["registry:register", "node:credentials:read"],
|
|
358
|
+
// auditor needs to walk the full node graph
|
|
359
|
+
"@my-company/node-flow-auditor": ["registry:register", "node:list", "node:wires:read"],
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
This pattern is already established in the Node-RED ecosystem — `@node-red/nodes`, `@node-red/runtime`, and `@node-red/editor-api` are all separate packages under the `@node-red` namespace.
|
|
365
|
+
|
|
366
|
+
### Service nodes as capability brokers
|
|
367
|
+
|
|
368
|
+
Sentinel resolves capabilities by looking at the **nearest** user-installed package in the call stack. This means a "service" package that wraps privileged operations acts as a capability broker: only the service needs the grant, not the packages that call into it.
|
|
369
|
+
|
|
370
|
+
**How it works:** When package A calls a method in package B, and package B internally makes a privileged call (e.g. `fs.readFileSync`), the call stack looks like this:
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
fs.readFileSync ← built-in (skipped)
|
|
374
|
+
node-red-contrib-file-service/index.js:55 ← nearest userDir frame → checked
|
|
375
|
+
node-red-contrib-my-processor/index.js:12 ← outer frame (not checked for this call)
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Sentinel finds `node-red-contrib-file-service` first and checks its grants. `node-red-contrib-my-processor` is not involved in the capability check at all.
|
|
379
|
+
|
|
380
|
+
**In practice:** publish a service package that wraps privileged operations behind a controlled API, grant it the capabilities it needs, and let consumer packages call it freely:
|
|
381
|
+
|
|
382
|
+
```js
|
|
383
|
+
// node-red-contrib-file-service/index.js
|
|
384
|
+
// This package holds fs:read — consumers don't need it.
|
|
385
|
+
module.exports = function (RED) {
|
|
386
|
+
function FileServiceNode(config) {
|
|
387
|
+
RED.nodes.createNode(this, config);
|
|
388
|
+
// Exposed API — consumers call node.readConfig(), not fs directly.
|
|
389
|
+
this.readConfig = function (filePath) {
|
|
390
|
+
return require("fs").readFileSync(filePath, "utf8");
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
RED.nodes.registerType("file-service", FileServiceNode);
|
|
394
|
+
};
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
// node-red-contrib-my-processor/index.js
|
|
399
|
+
// No fs capability needed — reads files through the service node.
|
|
400
|
+
module.exports = function (RED) {
|
|
401
|
+
function ProcessorNode(config) {
|
|
402
|
+
RED.nodes.createNode(this, config);
|
|
403
|
+
var service = RED.nodes.getNode(config.serviceId);
|
|
404
|
+
this.on("input", function (msg) {
|
|
405
|
+
var data = service.readConfig("/data/config.json"); // service makes the fs call
|
|
406
|
+
// ... process data
|
|
407
|
+
this.send(msg);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
RED.nodes.registerType("my-processor", ProcessorNode);
|
|
411
|
+
};
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
```js
|
|
415
|
+
// settings.js
|
|
416
|
+
sentinel: {
|
|
417
|
+
allow: {
|
|
418
|
+
// Only the service needs fs:read — it owns the privileged boundary.
|
|
419
|
+
"node-red-contrib-file-service": ["registry:register", "fs:read"],
|
|
420
|
+
// The consumer needs no capability beyond registering its node type.
|
|
421
|
+
"node-red-contrib-my-processor": ["registry:register"],
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
This pattern is useful when multiple consumer packages need the same privileged operation: centralise it in one well-audited service package, grant only that package the capability, and consumers remain unprivileged. The service becomes the policy enforcement point — it decides what it exposes, and Sentinel enforces that nothing bypasses it.
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
## Module access gates
|
|
430
|
+
|
|
431
|
+
Sentinel intercepts `require()` for dangerous built-in modules and blocks specific methods within them. When a call is blocked, Sentinel prints a warning to the Node-RED console and tells you exactly which grant to add.
|
|
432
|
+
|
|
433
|
+
The warning format is:
|
|
434
|
+
|
|
435
|
+
```
|
|
436
|
+
[@allanoricil/nrg-sentinel] BLOCKED fs.readFileSync() — my-custom-node lacks fs:read
|
|
437
|
+
Call stack:
|
|
438
|
+
at Object.<anonymous> (/data/node_modules/my-custom-node/index.js:42:5)
|
|
439
|
+
To allow, add to settings.js:
|
|
440
|
+
sentinel: { allow: { "my-custom-node": ["fs:read"] } }
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
For modules that are blocked entirely at `require()` time (like `vm` and `worker_threads`), the operation throws immediately:
|
|
444
|
+
|
|
445
|
+
```
|
|
446
|
+
[@allanoricil/nrg-sentinel] BLOCKED require('vm') — my-custom-node lacks vm:execute
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### File system — `fs:read` and `fs:write`
|
|
450
|
+
|
|
451
|
+
Triggered by `require('fs')`, `require('fs/promises')`, `require('node:fs')`, `require('node:fs/promises')`.
|
|
452
|
+
|
|
453
|
+
| What you call | Cap needed |
|
|
454
|
+
| -------------------------------------------------------------------------------------------- | ---------- |
|
|
455
|
+
| `readFile`, `readFileSync`, `readdir`, `createReadStream`, `stat`, `exists`, `watch` | `fs:read` |
|
|
456
|
+
| `writeFile`, `writeFileSync`, `appendFile`, `createWriteStream`, `unlink`, `mkdir`, `rename` | `fs:write` |
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
// Node-RED log when blocked:
|
|
460
|
+
// [@allanoricil/nrg-sentinel] BLOCKED fs.readFileSync() — my-node lacks fs:read
|
|
461
|
+
|
|
462
|
+
sentinel: {
|
|
463
|
+
allow: {
|
|
464
|
+
"my-node": ["registry:register", "fs:read"],
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Outbound HTTP — `network:http` and `network:fetch`
|
|
470
|
+
|
|
471
|
+
Triggered when a node calls `http.request()`, `https.request()`, `http.get()`, or the global `fetch()`.
|
|
472
|
+
|
|
473
|
+
```js
|
|
474
|
+
// Node-RED log when blocked:
|
|
475
|
+
// [@allanoricil/nrg-sentinel] BLOCKED http.request() — my-node lacks network:http
|
|
476
|
+
// NRG Sentinel: network:fetch not granted — my-node
|
|
477
|
+
|
|
478
|
+
sentinel: {
|
|
479
|
+
allow: {
|
|
480
|
+
"my-node": ["registry:register", "network:http"],
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
You can further restrict which URLs are reachable using the network allowlist:
|
|
486
|
+
|
|
487
|
+
```js
|
|
488
|
+
sentinel: {
|
|
489
|
+
allow: {
|
|
490
|
+
"my-node": ["registry:register", "network:http"],
|
|
491
|
+
},
|
|
492
|
+
networkPolicy: {
|
|
493
|
+
allowlist: [
|
|
494
|
+
"https://api.example.com/",
|
|
495
|
+
"https://metrics.internal/",
|
|
496
|
+
],
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### Raw TCP/UDP sockets — `network:socket`
|
|
502
|
+
|
|
503
|
+
Triggered by `net.createConnection()`, `tls.connect()`, `dgram.createSocket()`. These bypass the HTTP URL allowlist entirely — a package with only `network:http` cannot open raw sockets.
|
|
504
|
+
|
|
505
|
+
```js
|
|
506
|
+
// Node-RED log when blocked:
|
|
507
|
+
// [@allanoricil/nrg-sentinel] BLOCKED net.createConnection() — my-node lacks network:socket
|
|
508
|
+
|
|
509
|
+
sentinel: {
|
|
510
|
+
allow: {
|
|
511
|
+
"my-node": ["registry:register", "network:socket"],
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### DNS lookups — `network:dns`
|
|
517
|
+
|
|
518
|
+
Triggered by `require('dns').lookup()`, `resolve()`, and all other dns methods, including `dns/promises` variants. DNS is a known data-exfiltration channel (subdomains can encode data to an attacker-controlled nameserver).
|
|
519
|
+
|
|
520
|
+
```js
|
|
521
|
+
// Node-RED log when blocked:
|
|
522
|
+
// [@allanoricil/nrg-sentinel] BLOCKED dns.lookup() — my-node lacks network:dns
|
|
523
|
+
|
|
524
|
+
sentinel: {
|
|
525
|
+
allow: {
|
|
526
|
+
"my-node": ["registry:register", "network:dns"],
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### Child processes — `process:exec`
|
|
532
|
+
|
|
533
|
+
Triggered by `child_process.exec()`, `execSync()`, `spawn()`, `spawnSync()`, `execFile()`, `fork()`.
|
|
534
|
+
|
|
535
|
+
```js
|
|
536
|
+
// Node-RED log when blocked:
|
|
537
|
+
// [@allanoricil/nrg-sentinel] BLOCKED child_process.execSync() — process:exec not granted for my-node
|
|
538
|
+
|
|
539
|
+
sentinel: {
|
|
540
|
+
allow: {
|
|
541
|
+
"my-node": ["registry:register", "process:exec"],
|
|
542
|
+
},
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### Environment variables — `process:env:read`
|
|
547
|
+
|
|
548
|
+
Triggered when a node reads `process.env.SOME_KEY`. This gates reads from the global `process.env` object.
|
|
549
|
+
|
|
550
|
+
```js
|
|
551
|
+
// Node-RED log when blocked:
|
|
552
|
+
// [@allanoricil/nrg-sentinel] BLOCKED process.env.DATABASE_URL — my-node lacks process:env:read
|
|
553
|
+
|
|
554
|
+
sentinel: {
|
|
555
|
+
allow: {
|
|
556
|
+
"my-node": ["registry:register", "process:env:read"],
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
### VM contexts — `vm:execute`
|
|
562
|
+
|
|
563
|
+
The entire `require('vm')` call is blocked if the caller lacks this capability. Code run inside a `vm` context bypasses all `Module._load` hooks — Sentinel cannot see what it does.
|
|
564
|
+
|
|
565
|
+
```js
|
|
566
|
+
// Node-RED log when blocked (throws, does not just warn):
|
|
567
|
+
// [@allanoricil/nrg-sentinel] BLOCKED require('vm') — my-node lacks vm:execute
|
|
568
|
+
|
|
569
|
+
sentinel: {
|
|
570
|
+
allow: {
|
|
571
|
+
"my-node": ["registry:register", "vm:execute"],
|
|
572
|
+
},
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Worker threads — `threads:spawn`
|
|
577
|
+
|
|
578
|
+
The entire `require('worker_threads')` call is blocked. Workers run in a separate V8 isolate whose module loader is invisible to Sentinel.
|
|
579
|
+
|
|
580
|
+
```js
|
|
581
|
+
// Node-RED log when blocked (throws, does not just warn):
|
|
582
|
+
// [@allanoricil/nrg-sentinel] BLOCKED require('worker_threads') — my-node lacks threads:spawn
|
|
583
|
+
|
|
584
|
+
sentinel: {
|
|
585
|
+
allow: {
|
|
586
|
+
"my-node": ["registry:register", "threads:spawn"],
|
|
587
|
+
},
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
## Local / Host install
|
|
592
|
+
|
|
593
|
+
Install Sentinel into your Node-RED user directory:
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
cd ~/.node-red
|
|
597
|
+
npm install @allanoricil/nrg-sentinel
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
Node-RED auto-discovers plugins in `~/.node-red/node_modules/`, so the Sentinel sidebar and plugin features load automatically on the next restart. No extra configuration is needed for that.
|
|
601
|
+
|
|
602
|
+
To activate the **preload guard** (module-level interception), set `NODE_OPTIONS` before starting Node-RED:
|
|
603
|
+
|
|
604
|
+
```bash
|
|
605
|
+
NODE_OPTIONS="--require @allanoricil/nrg-sentinel/preload" node-red
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
To make this permanent, add it to your startup script, systemd unit, or shell profile:
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
# ~/.bashrc or ~/.zshrc
|
|
612
|
+
export NODE_OPTIONS="--require @allanoricil/nrg-sentinel/preload"
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
> **Why not `./node_modules/.bin/node-red`?**
|
|
616
|
+
> The `node-red` package itself is not installed inside `~/.node-red` — it lives in the global `node_modules`. The Sentinel wrapper binary handles both cases automatically: when `node-red` is co-installed in the same `node_modules` tree (Docker) it resolves the entrypoint directly; otherwise it finds `node-red` via PATH. Either way, the preload is injected via `NODE_OPTIONS`.
|
|
617
|
+
|
|
618
|
+
## Docker
|
|
619
|
+
|
|
620
|
+
The [`Dockerfile`](Dockerfile) produces a hardened production image. The security model rests on three layers.
|
|
621
|
+
|
|
622
|
+
### Filesystem layout
|
|
623
|
+
|
|
624
|
+
```
|
|
625
|
+
/usr/src/nodered owned by root, chmod a-w Node-RED + Sentinel install
|
|
626
|
+
/etc/nodered owned by root, chmod a-w settings.js (read-only config)
|
|
627
|
+
/data owned by nodered flows, credentials, custom nodes
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
The Node-RED process runs as the unprivileged `nodered` user. It can read everything it needs and write only to `/data`. It cannot modify the Sentinel or Node-RED installation on disk, even if a malicious custom node executes code inside the process.
|
|
631
|
+
|
|
632
|
+
The write bit is stripped from `/usr/src/nodered` for **everyone, including root** (`chmod -R a-w`). Any attempt to silently patch Sentinel or Node-RED would require an explicit `chmod` first — which is visible in audit logs.
|
|
633
|
+
|
|
634
|
+
### Why `settings.js` lives in `/etc/nodered`
|
|
635
|
+
|
|
636
|
+
`settings.js` controls the capability grants for every node. Moving it out of `/data` (the writable zone) means a malicious node cannot edit the file to grant itself new permissions at runtime. It is mounted read-only from the host:
|
|
637
|
+
|
|
638
|
+
```bash
|
|
639
|
+
-v $(pwd)/settings.js:/etc/nodered/settings.js:ro
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### Why the entrypoint is an absolute path
|
|
643
|
+
|
|
644
|
+
The `ENTRYPOINT` hardcodes the full path to Sentinel's wrapper binary:
|
|
645
|
+
|
|
646
|
+
```
|
|
647
|
+
dumb-init -- node /usr/src/nodered/node_modules/@allanoricil/nrg-sentinel/bin/node-red.js
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
This bypasses `node_modules/.bin/` entirely. A malicious package that declares `"bin": { "node-red": "..." }` in its `package.json` cannot displace the entrypoint because the container never resolves it through PATH or npm's bin symlinks.
|
|
651
|
+
|
|
652
|
+
`dumb-init` runs as PID 1 and correctly forwards OS signals (e.g. `SIGTERM` from `docker stop`) to Node-RED, solving the standard PID 1 signal-forwarding problem and ensuring graceful shutdown.
|
|
653
|
+
|
|
654
|
+
The wrapper:
|
|
655
|
+
|
|
656
|
+
1. Verifies `settings.js` signature before Node-RED starts (if `NRG_SENTINEL_PUBLIC_KEY` is set)
|
|
657
|
+
2. Injects the Sentinel preload by prepending `--require preload.js` to `NODE_OPTIONS`, then spawns the real `node-red` binary
|
|
658
|
+
|
|
659
|
+
In the Docker image both packages share `/usr/src/nodered/node_modules/`, so the wrapper resolves the `node-red` JS entrypoint via `require.resolve` and runs it with `node` directly. When installed in a userDir (`~/.node-red`) the fallback is to find `node-red` in PATH.
|
|
660
|
+
|
|
661
|
+
### Quick start
|
|
662
|
+
|
|
663
|
+
Pre-built images are published to Docker Hub on every release:
|
|
664
|
+
|
|
665
|
+
```bash
|
|
666
|
+
docker pull allanoricil/nrg-sentinel:latest
|
|
667
|
+
# or pin to a specific version
|
|
668
|
+
docker pull allanoricil/nrg-sentinel:1.2.3
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
To build from source instead:
|
|
672
|
+
|
|
673
|
+
```bash
|
|
674
|
+
docker build -t nrg-sentinel .
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
```bash
|
|
678
|
+
# Run (no signature verification)
|
|
679
|
+
docker run -p 1880:1880 \
|
|
680
|
+
-v $(pwd)/settings.js:/etc/nodered/settings.js:ro \
|
|
681
|
+
-v $(pwd)/data:/data \
|
|
682
|
+
allanoricil/nrg-sentinel:latest
|
|
683
|
+
|
|
684
|
+
# Run (with signature verification)
|
|
685
|
+
docker run -p 1880:1880 \
|
|
686
|
+
-v $(pwd)/settings.js:/etc/nodered/settings.js:ro \
|
|
687
|
+
-v $(pwd)/settings.js.sig:/etc/nodered/settings.js.sig:ro \
|
|
688
|
+
-v $(pwd)/data:/data \
|
|
689
|
+
-e NRG_SENTINEL_PUBLIC_KEY=/run/secrets/sentinel.pub \
|
|
690
|
+
--mount type=secret,id=sentinel_pub,target=/run/secrets/sentinel.pub \
|
|
691
|
+
allanoricil/nrg-sentinel:latest
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
## Licensing
|
|
695
|
+
|
|
696
|
+
NRG Sentinel is source-available software with a commercial license for production use beyond the built-in 14-day trial. No license key is required to evaluate the product.
|
|
697
|
+
|
|
698
|
+
### License verification — offline only
|
|
699
|
+
|
|
700
|
+
License keys are verified **entirely on the local machine**. No data is sent to any server during verification, and no internet connection is required at any point.
|
|
701
|
+
|
|
702
|
+
A license key is a signed token in the form `<base64url(payload)>.<Ed25519-signature>`. The payload is a JSON object with the following fields:
|
|
703
|
+
|
|
704
|
+
| Field | Description |
|
|
705
|
+
| --------- | ----------------------------------------------------------------------------------- |
|
|
706
|
+
| `product` | Always `nrg-sentinel` — prevents a key issued for another product from being reused |
|
|
707
|
+
| `tier` | License tier: `trial`, `pro`, `enterprise`, or `oem` |
|
|
708
|
+
| `exp` | Unix timestamp of expiry; `0` means perpetual |
|
|
709
|
+
| `cid` | Customer identifier (email or UUID) — recorded in the Sentinel startup log |
|
|
710
|
+
|
|
711
|
+
Verification steps performed locally at startup:
|
|
712
|
+
|
|
713
|
+
1. Decode and verify the Ed25519 signature against the public key baked into the distribution
|
|
714
|
+
2. Confirm `product === "nrg-sentinel"`
|
|
715
|
+
3. If `exp !== 0`, confirm the current time has not passed the expiry timestamp
|
|
716
|
+
|
|
717
|
+
The Ed25519 public key is embedded directly in `plugin.js` at build time (`SENTINEL_LICENSE_PUBLIC_KEY=<64-char-hex> node build.js`) and then obfuscated along with the rest of the plugin source. The corresponding private key is never included in the distribution and is never transmitted. An attacker who extracts the public key from the binary cannot forge a license — Ed25519 signatures are computationally infeasible to produce without the private key.
|
|
718
|
+
|
|
719
|
+
### What is never sent anywhere
|
|
720
|
+
|
|
721
|
+
- Your license key or customer ID
|
|
722
|
+
- The Node-RED host name, IP address, or any machine fingerprint
|
|
723
|
+
- Any flow, node, credential, or payload data
|
|
724
|
+
|
|
725
|
+
Sentinel makes no outbound network calls for licensing purposes. This is a deliberate design decision: NRG Sentinel is routinely deployed in restricted, air-gapped, or regulated environments where phone-home behaviour would be a hard blocker.
|
|
726
|
+
|
|
727
|
+
### Online activation
|
|
728
|
+
|
|
729
|
+
The npm package uses **offline-only** verification. There is no license server, no activation endpoint, and no requirement for internet access — now or after deployment.
|
|
730
|
+
|
|
731
|
+
If you need centrally managed license revocation (for example, if you are embedding Sentinel in an OEM product and need to rotate keys without reinstalling), the Sentinel Launcher binary supports optional online activation. Contact us for details.
|
|
732
|
+
|
|
733
|
+
### Trial period
|
|
734
|
+
|
|
735
|
+
The 14-day trial is counted from the first time Sentinel initialises in a given Node-RED instance. During the trial all features are fully available. After the trial expires, Sentinel remains active and continues blocking threats, but certain management features (such as the Permissions UI) require a valid license key.
|
|
736
|
+
|
|
737
|
+
### Configuring a license key
|
|
738
|
+
|
|
739
|
+
```js
|
|
740
|
+
// settings.js
|
|
741
|
+
module.exports = {
|
|
742
|
+
sentinel: {
|
|
743
|
+
license: "eyJ...", // license key issued by NRG
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
Alternatively, set the `NRG_SENTINEL_LICENSE` environment variable — useful in containerised deployments where `settings.js` is mounted read-only.
|