@eventra_dev/eventra-cli 1.0.3 → 1.0.5
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 +28 -4
- package/dist/analysis/extractor/extractor.js +7 -1
- package/dist/analysis/extractor/propagationExtractor.js +7 -3
- package/dist/analysis/scanner/scanner.js +2 -1
- package/dist/analysis/shared/dynamicOccurrence.js +22 -0
- package/dist/cli/check.js +2 -1
- package/dist/cli/init.js +7 -1
- package/dist/cli/send.js +36 -9
- package/dist/cli/sync.js +2 -1
- package/dist/config/config.js +81 -1
- package/dist/core/EventraEngine.js +8 -0
- package/dist/core/projectScanner.js +1 -0
- package/dist/index.js +3 -2
- package/dist/plugin/builtins/consoleDynamicEventReporter.js +24 -0
- package/dist/plugin/loadPlugins.js +23 -1
- package/dist/plugin/registry.js +34 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<a href="https://www.npmjs.com/package/@eventra_dev/eventra-cli"><img alt="npm version" src="https://img.shields.io/npm/v/@eventra_dev/eventra-cli.svg?style=flat-square&color=blue"></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/@eventra_dev/eventra-cli"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@eventra_dev/eventra-cli.svg?style=flat-square&color=blue"></a>
|
|
10
10
|
<a href="https://github.com/and-1991/eventra-cli/actions/workflows/test.yml"><img alt="tests" src="https://img.shields.io/github/actions/workflow/status/and-1991/eventra-cli/test.yml?branch=main&label=tests&style=flat-square&logo=vitest&logoColor=white"></a>
|
|
11
|
-
<img alt="unit tests" src="https://img.shields.io/badge/unit-
|
|
11
|
+
<img alt="unit tests" src="https://img.shields.io/badge/unit-74%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
|
|
12
12
|
<img alt="e2e fixtures" src="https://img.shields.io/badge/e2e-12%20fixtures-brightgreen?style=flat-square">
|
|
13
13
|
<img alt="node" src="https://img.shields.io/node/v/@eventra_dev/eventra-cli?style=flat-square&color=darkgreen&logo=node.js&logoColor=white">
|
|
14
14
|
<a href="https://www.typescriptlang.org/"><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-ready-blue?style=flat-square&logo=typescript&logoColor=white"></a>
|
|
@@ -155,7 +155,23 @@ Incremental scan with the same rules as `sync`.
|
|
|
155
155
|
|
|
156
156
|
### `eventra send`
|
|
157
157
|
|
|
158
|
-
Uploads `events` from config to the Eventra API. Requires
|
|
158
|
+
Uploads `events` from config to the Eventra API. Requires an API key and (optionally) an `endpoint`. Events are POSTed to `POST /api/v1/cli/events` and marked as non-billable on the backend.
|
|
159
|
+
|
|
160
|
+
**API key resolution** (checked in this order, never written to `eventra.json`):
|
|
161
|
+
|
|
162
|
+
1. `EVENTRA_API_KEY` environment variable — recommended for CI
|
|
163
|
+
2. `eventra.local.json` (created by `eventra init`/`eventra send`, automatically gitignored)
|
|
164
|
+
3. legacy inline `apiKey` in `eventra.json` (deprecated — avoid committing real keys here)
|
|
165
|
+
|
|
166
|
+
`eventra.json` is meant to be committed so CI can diff it via `eventra check`; keeping the key out of it avoids leaking it into source control. In a non-interactive shell (no TTY), `send` fails fast with a message pointing at `EVENTRA_API_KEY` instead of hanging on a prompt.
|
|
167
|
+
|
|
168
|
+
**Endpoint trust.** The default production endpoint is always used with no extra step. A *custom* `endpoint` is only trusted without approval when it comes from the `EVENTRA_ENDPOINT` environment variable (setting an env var is already a local/CI action). A custom `endpoint` committed inline in `eventra.json` is trust-on-first-use: `send` refuses to run until it's approved once with:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
eventra send --trust-endpoint
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This records the endpoint in `eventra.local.json` (gitignored). If `eventra.json`'s `endpoint` later changes to a different value — e.g. a PR silently pointing it at another host — `send` blocks again until re-approved, so a committed config change alone can't redirect where your API key and events get sent.
|
|
159
175
|
|
|
160
176
|
Network resilience:
|
|
161
177
|
|
|
@@ -224,6 +240,8 @@ External plugins export an object (or factory) with:
|
|
|
224
240
|
|
|
225
241
|
No dependency on `@eventra_dev/eventra-cli` is required. See [`ARCHITECTURE.md`](./ARCHITECTURE.md#plugin-kernel-phase-15--foundation-shipped) for details.
|
|
226
242
|
|
|
243
|
+
**Only official `@eventra_dev/cli-plugin-*` packages can be loaded.** `eventra.json` is meant to be committed to git, so its `plugins` array is effectively PR-editable; since the CLI `import()`s whatever is listed there, an arbitrary specifier would be arbitrary code execution on every machine that runs `sync`/`check`/`watch`. Because `@eventra_dev` is an npm scope only the Eventra maintainers can publish to, restricting to `@eventra_dev/cli-plugin-*` means nothing outside that scope can ever be loaded this way. Any other entry in `plugins` is skipped with a console warning instead of being imported — third-party/community plugins aren't supported today.
|
|
244
|
+
|
|
227
245
|
---
|
|
228
246
|
|
|
229
247
|
## Configuration
|
|
@@ -244,10 +262,14 @@ No dependency on `@eventra_dev/eventra-cli` is required. See [`ARCHITECTURE.md`]
|
|
|
244
262
|
|
|
245
263
|
| Field | Description |
|
|
246
264
|
|-------|-------------|
|
|
247
|
-
| `
|
|
265
|
+
| `apiKey` | Legacy inline API key — leave empty. Use `EVENTRA_API_KEY` or `eventra.local.json` instead (see [`eventra send`](#eventra-send)) so a real key never ends up in this committed file |
|
|
266
|
+
| `endpoint` | Custom `eventra send` target (e.g. self-hosted). Non-default values from this file need one-time local approval — see [`eventra send`](#eventra-send) |
|
|
267
|
+
| `plugins` | `@eventra_dev/cli-plugin-*` specifiers to `import()` at startup. Anything outside that scope is skipped (see [Plugin contract](#plugin-contract-for-authors)) |
|
|
248
268
|
| `sync.include` | Base glob patterns; plugin `includeGlobs` are merged automatically |
|
|
249
269
|
| `sync.exclude` | Paths skipped during scan |
|
|
250
270
|
|
|
271
|
+
`eventra init`/`eventra send` never write a real key into this file — they write to `eventra.local.json` instead, which is automatically added to `.gitignore`.
|
|
272
|
+
|
|
251
273
|
---
|
|
252
274
|
|
|
253
275
|
## How it works
|
|
@@ -275,7 +297,7 @@ No runtime execution. No monkey-patching.
|
|
|
275
297
|
|
|
276
298
|
## Test Coverage
|
|
277
299
|
|
|
278
|
-
Two test layers, **
|
|
300
|
+
Two test layers, **74 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios**.
|
|
279
301
|
|
|
280
302
|
**Unit tests (vitest)** — 13 suites covering core modules:
|
|
281
303
|
|
|
@@ -322,6 +344,8 @@ pnpm --filter @eventra_dev/eventra-cli test:unit # vitest only
|
|
|
322
344
|
pnpm --filter @eventra_dev/eventra-cli test:e2e # fixtures + check exit codes
|
|
323
345
|
```
|
|
324
346
|
|
|
347
|
+
> **Node version note:** the CLI itself supports Node 18+ (see `engines`), but `vitest@4` depends on `node:util`'s `styleText`, which requires Node **≥ 20.12** — running the test suite on an older Node 20.x patch (or Node 18) fails to start. CI runs the matrix on Node 20 and 22; locally, use the version pinned in the repo's `.nvmrc`.
|
|
348
|
+
|
|
325
349
|
---
|
|
326
350
|
|
|
327
351
|
## License
|
|
@@ -9,8 +9,10 @@ const resolver_1 = require("../resolver/resolver");
|
|
|
9
9
|
const evaluationContext_1 = require("../shared/evaluationContext");
|
|
10
10
|
const propagationExtractor_1 = require("./propagationExtractor");
|
|
11
11
|
const eventValidation_1 = require("../shared/eventValidation");
|
|
12
|
+
const dynamicOccurrence_1 = require("../shared/dynamicOccurrence");
|
|
12
13
|
function extractEvents(index, checker, _config, evaluationCache, exportCache, resolvedCallCache, returnPropagationCache, wrapperRegistry) {
|
|
13
14
|
const events = new Set();
|
|
15
|
+
const dynamicOccurrences = [];
|
|
14
16
|
// direct track sinks
|
|
15
17
|
for (const call of index.trackCalls) {
|
|
16
18
|
for (const argument of call.trackedArguments) {
|
|
@@ -21,12 +23,15 @@ function extractEvents(index, checker, _config, evaluationCache, exportCache, re
|
|
|
21
23
|
events.add(normalized);
|
|
22
24
|
}
|
|
23
25
|
}
|
|
26
|
+
if (resolved.dynamic) {
|
|
27
|
+
(0, dynamicOccurrence_1.recordDynamicOccurrence)(call.node, resolved.values, dynamicOccurrences);
|
|
28
|
+
}
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
const propagationVisited = new Set();
|
|
27
32
|
function visit(node) {
|
|
28
33
|
if (typescript_1.default.isCallExpression(node)) {
|
|
29
|
-
(0, propagationExtractor_1.extractPropagationEvents)(node, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, propagationVisited);
|
|
34
|
+
(0, propagationExtractor_1.extractPropagationEvents)(node, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, propagationVisited, dynamicOccurrences);
|
|
30
35
|
}
|
|
31
36
|
typescript_1.default.forEachChild(node, visit);
|
|
32
37
|
}
|
|
@@ -34,5 +39,6 @@ function extractEvents(index, checker, _config, evaluationCache, exportCache, re
|
|
|
34
39
|
return {
|
|
35
40
|
events,
|
|
36
41
|
detectedFunctionWrappers: new Set(index.wrappers.map(wrapper => wrapper.symbol.getName())),
|
|
42
|
+
dynamicOccurrences,
|
|
37
43
|
};
|
|
38
44
|
}
|
|
@@ -9,6 +9,7 @@ const callResolver_1 = require("../resolver/callResolver");
|
|
|
9
9
|
const resolver_1 = require("../resolver/resolver");
|
|
10
10
|
const evaluationContext_1 = require("../shared/evaluationContext");
|
|
11
11
|
const eventValidation_1 = require("../shared/eventValidation");
|
|
12
|
+
const dynamicOccurrence_1 = require("../shared/dynamicOccurrence");
|
|
12
13
|
function resolveObjectPath(expression, path) {
|
|
13
14
|
let current = expression;
|
|
14
15
|
for (const segment of path) {
|
|
@@ -34,7 +35,7 @@ function resolveObjectPath(expression, path) {
|
|
|
34
35
|
}
|
|
35
36
|
return current;
|
|
36
37
|
}
|
|
37
|
-
function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, visited) {
|
|
38
|
+
function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, visited, dynamicOccurrences) {
|
|
38
39
|
const resolved = (0, callResolver_1.resolveFunctionFromCall)(call.expression, checker, resolvedCallCache, exportCache);
|
|
39
40
|
if (!resolved) {
|
|
40
41
|
return;
|
|
@@ -53,14 +54,14 @@ function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCa
|
|
|
53
54
|
visited.add(signature);
|
|
54
55
|
try {
|
|
55
56
|
for (const propagation of semantic.propagations) {
|
|
56
|
-
extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events);
|
|
57
|
+
extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events, dynamicOccurrences);
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
finally {
|
|
60
61
|
visited.delete(signature);
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
|
-
function extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events) {
|
|
64
|
+
function extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events, dynamicOccurrences) {
|
|
64
65
|
let argument = call.arguments[propagation.sourceParameterIndex];
|
|
65
66
|
if (!argument) {
|
|
66
67
|
return;
|
|
@@ -89,4 +90,7 @@ function extractPropagation(propagation, call, checker, evaluationCache, resolve
|
|
|
89
90
|
events.add(normalized);
|
|
90
91
|
}
|
|
91
92
|
}
|
|
93
|
+
if (resolved.dynamic) {
|
|
94
|
+
(0, dynamicOccurrence_1.recordDynamicOccurrence)(call, resolved.values, dynamicOccurrences);
|
|
95
|
+
}
|
|
92
96
|
}
|
|
@@ -72,7 +72,8 @@ function scanSource(source, checker, wrapperRegistry, plugins) {
|
|
|
72
72
|
}
|
|
73
73
|
return false;
|
|
74
74
|
});
|
|
75
|
-
const wrapper = (
|
|
75
|
+
const wrapper = plugins.detectWrapper({ fn, checker, sinks: localSinks }) ??
|
|
76
|
+
(0, propagationAnalyzer_1.analyzeWrapperPropagation)(fn, checker, localSinks);
|
|
76
77
|
if (!wrapper) {
|
|
77
78
|
continue;
|
|
78
79
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/analysis/shared/dynamicOccurrence.ts
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.recordDynamicOccurrence = recordDynamicOccurrence;
|
|
8
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
9
|
+
const CALL_TEXT_MAX_LENGTH = 120;
|
|
10
|
+
function recordDynamicOccurrence(call, resolvedValues, out) {
|
|
11
|
+
const sourceFile = call.getSourceFile();
|
|
12
|
+
const { line, character } = typescript_1.default.getLineAndCharacterOfPosition(sourceFile, call.getStart(sourceFile));
|
|
13
|
+
const callText = call.getText(sourceFile);
|
|
14
|
+
out.push({
|
|
15
|
+
fileName: sourceFile.fileName,
|
|
16
|
+
line: line + 1,
|
|
17
|
+
character,
|
|
18
|
+
calleeText: call.expression.getText(sourceFile),
|
|
19
|
+
callText: callText.length > CALL_TEXT_MAX_LENGTH ? `${callText.slice(0, CALL_TEXT_MAX_LENGTH)}…` : callText,
|
|
20
|
+
resolvedValues,
|
|
21
|
+
});
|
|
22
|
+
}
|
package/dist/cli/check.js
CHANGED
|
@@ -21,7 +21,8 @@ async function check({ fix = false }) {
|
|
|
21
21
|
process.exit(1);
|
|
22
22
|
}
|
|
23
23
|
console.log(chalk_1.default.blue("Checking events and function wrappers..."));
|
|
24
|
-
const { engine } = await (0, projectScanner_1.scanProject)(config);
|
|
24
|
+
const { engine, plugins } = await (0, projectScanner_1.scanProject)(config);
|
|
25
|
+
await plugins.runDynamicEventReporters(engine.getAllDynamicOccurrences());
|
|
25
26
|
const foundEvents = new Set(engine.getAllEvents());
|
|
26
27
|
const knownEvents = new Set(config.events ?? []);
|
|
27
28
|
const eventDiff = diffSets(foundEvents, knownEvents);
|
package/dist/cli/init.js
CHANGED
|
@@ -29,7 +29,7 @@ async function init() {
|
|
|
29
29
|
console.log(chalk_1.default.gray("• cross-file propagation chains"));
|
|
30
30
|
console.log("");
|
|
31
31
|
const config = {
|
|
32
|
-
apiKey,
|
|
32
|
+
apiKey: "",
|
|
33
33
|
endpoint,
|
|
34
34
|
events: [],
|
|
35
35
|
functionWrappers: [],
|
|
@@ -47,6 +47,12 @@ async function init() {
|
|
|
47
47
|
};
|
|
48
48
|
await (0, config_1.saveConfig)(config);
|
|
49
49
|
console.log(chalk_1.default.green("eventra.json created"));
|
|
50
|
+
// Never write the key into eventra.json — that file is meant to be
|
|
51
|
+
// committed (CI diffs it via `eventra check`). Keep secrets out of git.
|
|
52
|
+
if (apiKey) {
|
|
53
|
+
await (0, config_1.saveLocalApiKey)(apiKey);
|
|
54
|
+
console.log(chalk_1.default.green("API key saved to eventra.local.json (added to .gitignore)"));
|
|
55
|
+
}
|
|
50
56
|
console.log("");
|
|
51
57
|
console.log(chalk_1.default.gray("Run `eventra sync`"));
|
|
52
58
|
}
|
package/dist/cli/send.js
CHANGED
|
@@ -9,6 +9,8 @@ const inquirer_1 = __importDefault(require("inquirer"));
|
|
|
9
9
|
const config_1 = require("../config/config");
|
|
10
10
|
const package_json_1 = __importDefault(require("../../package.json"));
|
|
11
11
|
const CLI_VERSION = package_json_1.default.version;
|
|
12
|
+
// Always trusted, no local approval needed — matches `eventra init`'s prompt default.
|
|
13
|
+
const DEFAULT_SEND_ENDPOINT = "https://api.eventra.dev/api/v1/cli/events";
|
|
12
14
|
// retry/backoff tuning (mirrors SDK defaults)
|
|
13
15
|
const MAX_ATTEMPTS = 4;
|
|
14
16
|
const BASE_DELAY_MS = 500;
|
|
@@ -56,16 +58,40 @@ async function attemptDelivery(endpoint, apiKey, payload) {
|
|
|
56
58
|
clearTimeout(timeout);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
async function send() {
|
|
61
|
+
async function send(opts = {}) {
|
|
60
62
|
const config = await (0, config_1.loadConfig)();
|
|
61
|
-
const endpoint = config?.endpoint || process.env.EVENTRA_ENDPOINT;
|
|
62
63
|
if (!config) {
|
|
63
64
|
console.log(chalk_1.default.red("eventra.json not found. Run 'eventra init'"));
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
-
//
|
|
67
|
+
const endpoint = config.endpoint || process.env.EVENTRA_ENDPOINT || undefined;
|
|
68
|
+
// A non-default endpoint sourced from the committed eventra.json (not from
|
|
69
|
+
// EVENTRA_ENDPOINT, which already requires a local/CI env-var change) needs
|
|
70
|
+
// one-time local approval — otherwise a PR silently editing `endpoint` could
|
|
71
|
+
// redirect send (and the API key) to an attacker's server.
|
|
72
|
+
if (endpoint &&
|
|
73
|
+
endpoint !== DEFAULT_SEND_ENDPOINT &&
|
|
74
|
+
endpoint === config.endpoint &&
|
|
75
|
+
!process.env.EVENTRA_ENDPOINT) {
|
|
76
|
+
const trusted = await (0, config_1.getTrustedEndpoint)();
|
|
77
|
+
if (endpoint !== trusted) {
|
|
78
|
+
if (!opts.trustEndpoint) {
|
|
79
|
+
console.log(chalk_1.default.red(`Endpoint "${endpoint}" from eventra.json is not locally approved.`));
|
|
80
|
+
console.log(chalk_1.default.gray("If this is expected (e.g. a self-hosted ingest URL), approve it once with:"));
|
|
81
|
+
console.log(chalk_1.default.gray(" eventra send --trust-endpoint"));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await (0, config_1.trustEndpoint)(endpoint);
|
|
85
|
+
console.log(chalk_1.default.green(`Endpoint "${endpoint}" approved and saved to eventra.local.json`));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let apiKey = await (0, config_1.resolveApiKey)(config);
|
|
89
|
+
// ask for the key only when interactive — never hang a CI/non-TTY run on a prompt
|
|
68
90
|
if (!apiKey) {
|
|
91
|
+
if (!process.stdin.isTTY) {
|
|
92
|
+
console.log(chalk_1.default.red("API key required. Set the EVENTRA_API_KEY environment variable (recommended for CI) or run 'eventra init'."));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
69
95
|
const answers = await inquirer_1.default.prompt([
|
|
70
96
|
{
|
|
71
97
|
type: "input",
|
|
@@ -74,11 +100,12 @@ async function send() {
|
|
|
74
100
|
}
|
|
75
101
|
]);
|
|
76
102
|
apiKey = answers.apiKey;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
if (apiKey) {
|
|
104
|
+
// Saved to the gitignored local secrets file, never to eventra.json —
|
|
105
|
+
// that file is meant to be committed for `eventra check` drift detection.
|
|
106
|
+
await (0, config_1.saveLocalApiKey)(apiKey);
|
|
107
|
+
console.log(chalk_1.default.green("API key saved to eventra.local.json (added to .gitignore)"));
|
|
108
|
+
}
|
|
82
109
|
}
|
|
83
110
|
// no events
|
|
84
111
|
if (!config.events?.length) {
|
package/dist/cli/sync.js
CHANGED
|
@@ -15,9 +15,10 @@ async function sync() {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
console.log(chalk_1.default.blue("Scanning..."));
|
|
18
|
-
const { engine } = await (0, projectScanner_1.scanProject)(config);
|
|
18
|
+
const { engine, plugins } = await (0, projectScanner_1.scanProject)(config);
|
|
19
19
|
await (0, scanResults_1.persistScanResults)(config, engine);
|
|
20
20
|
const events = engine.getAllEvents();
|
|
21
21
|
const wrappers = engine.getAllFunctionWrappers();
|
|
22
22
|
console.log(chalk_1.default.green(`Found ${events.length} events, ${wrappers.length} function wrappers`));
|
|
23
|
+
await plugins.runDynamicEventReporters(engine.getAllDynamicOccurrences());
|
|
23
24
|
}
|
package/dist/config/config.js
CHANGED
|
@@ -3,13 +3,22 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.CONFIG_NAME = void 0;
|
|
6
|
+
exports.LOCAL_CONFIG_NAME = exports.CONFIG_NAME = void 0;
|
|
7
7
|
exports.normalizeConfig = normalizeConfig;
|
|
8
8
|
exports.loadConfig = loadConfig;
|
|
9
9
|
exports.saveConfig = saveConfig;
|
|
10
|
+
exports.resolveApiKey = resolveApiKey;
|
|
11
|
+
exports.saveLocalApiKey = saveLocalApiKey;
|
|
12
|
+
exports.getTrustedEndpoint = getTrustedEndpoint;
|
|
13
|
+
exports.trustEndpoint = trustEndpoint;
|
|
10
14
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
11
15
|
const path_1 = __importDefault(require("path"));
|
|
12
16
|
exports.CONFIG_NAME = "eventra.json";
|
|
17
|
+
/**
|
|
18
|
+
* Secrets live here instead of `eventra.json`, since the latter is meant to be
|
|
19
|
+
* committed (CI diffs `events`/`functionWrappers` against it via `eventra check`).
|
|
20
|
+
*/
|
|
21
|
+
exports.LOCAL_CONFIG_NAME = "eventra.local.json";
|
|
13
22
|
const DEFAULT_INCLUDE = ["**/*.{ts,tsx,js,jsx}"];
|
|
14
23
|
const DEFAULT_EXCLUDE = ["node_modules", "dist", ".next", ".git"];
|
|
15
24
|
function normalizeConfig(config) {
|
|
@@ -52,3 +61,74 @@ async function saveConfig(config) {
|
|
|
52
61
|
const configPath = path_1.default.join(process.cwd(), exports.CONFIG_NAME);
|
|
53
62
|
await fs_extra_1.default.writeJSON(configPath, normalizeConfig(config), { spaces: 2 });
|
|
54
63
|
}
|
|
64
|
+
async function readLocalSecrets() {
|
|
65
|
+
const localPath = path_1.default.join(process.cwd(), exports.LOCAL_CONFIG_NAME);
|
|
66
|
+
if (!(await fs_extra_1.default.pathExists(localPath)))
|
|
67
|
+
return {};
|
|
68
|
+
try {
|
|
69
|
+
const parsed = await fs_extra_1.default.readJSON(localPath);
|
|
70
|
+
return {
|
|
71
|
+
apiKey: typeof parsed?.apiKey === "string" && parsed.apiKey ? parsed.apiKey : undefined,
|
|
72
|
+
trustedEndpoint: typeof parsed?.trustedEndpoint === "string" && parsed.trustedEndpoint
|
|
73
|
+
? parsed.trustedEndpoint
|
|
74
|
+
: undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function writeLocalSecrets(patch) {
|
|
82
|
+
const localPath = path_1.default.join(process.cwd(), exports.LOCAL_CONFIG_NAME);
|
|
83
|
+
const next = { ...(await readLocalSecrets()), ...patch };
|
|
84
|
+
const out = {};
|
|
85
|
+
if (next.apiKey)
|
|
86
|
+
out.apiKey = next.apiKey;
|
|
87
|
+
if (next.trustedEndpoint)
|
|
88
|
+
out.trustedEndpoint = next.trustedEndpoint;
|
|
89
|
+
await fs_extra_1.default.writeJSON(localPath, out, { spaces: 2 });
|
|
90
|
+
await ensureGitignored(exports.LOCAL_CONFIG_NAME);
|
|
91
|
+
}
|
|
92
|
+
async function ensureGitignored(entry) {
|
|
93
|
+
const gitignorePath = path_1.default.join(process.cwd(), ".gitignore");
|
|
94
|
+
let content = "";
|
|
95
|
+
if (await fs_extra_1.default.pathExists(gitignorePath)) {
|
|
96
|
+
content = await fs_extra_1.default.readFile(gitignorePath, "utf8");
|
|
97
|
+
if (content.split(/\r?\n/).some((line) => line.trim() === entry))
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
|
|
101
|
+
await fs_extra_1.default.appendFile(gitignorePath, `${needsLeadingNewline ? "\n" : ""}${entry}\n`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolves the API key for outbound requests only — never persisted back onto
|
|
105
|
+
* the `EventraConfig` object, so it can't leak into `eventra.json` via `saveConfig`.
|
|
106
|
+
* Priority: `EVENTRA_API_KEY` env var > `eventra.local.json` (gitignored) >
|
|
107
|
+
* legacy inline `apiKey` in `eventra.json` (kept for backward compatibility only).
|
|
108
|
+
*/
|
|
109
|
+
async function resolveApiKey(config) {
|
|
110
|
+
if (process.env.EVENTRA_API_KEY)
|
|
111
|
+
return process.env.EVENTRA_API_KEY;
|
|
112
|
+
const { apiKey } = await readLocalSecrets();
|
|
113
|
+
if (apiKey)
|
|
114
|
+
return apiKey;
|
|
115
|
+
return config.apiKey || undefined;
|
|
116
|
+
}
|
|
117
|
+
/** Persists an API key to the gitignored local secrets file, never to `eventra.json`. */
|
|
118
|
+
async function saveLocalApiKey(apiKey) {
|
|
119
|
+
await writeLocalSecrets({ apiKey });
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The `endpoint` a locally-run `eventra send` last approved, trust-on-first-use
|
|
123
|
+
* style — a non-default `endpoint` sourced from the *committed* `eventra.json`
|
|
124
|
+
* must match this before it's used, so a PR silently changing it can't redirect
|
|
125
|
+
* `send` (and the API key with it) without a human approving it locally once.
|
|
126
|
+
*/
|
|
127
|
+
async function getTrustedEndpoint() {
|
|
128
|
+
const { trustedEndpoint } = await readLocalSecrets();
|
|
129
|
+
return trustedEndpoint;
|
|
130
|
+
}
|
|
131
|
+
/** Records `endpoint` as locally approved (see `getTrustedEndpoint`). */
|
|
132
|
+
async function trustEndpoint(endpoint) {
|
|
133
|
+
await writeLocalSecrets({ trustedEndpoint: endpoint });
|
|
134
|
+
}
|
|
@@ -20,6 +20,7 @@ const loadPlugins_1 = require("../plugin/loadPlugins");
|
|
|
20
20
|
const EMPTY_RESULT = () => ({
|
|
21
21
|
events: new Set(),
|
|
22
22
|
detectedFunctionWrappers: new Set(),
|
|
23
|
+
dynamicOccurrences: [],
|
|
23
24
|
});
|
|
24
25
|
class EventraEngine {
|
|
25
26
|
compiler;
|
|
@@ -253,5 +254,12 @@ class EventraEngine {
|
|
|
253
254
|
getScanResult(fileName) {
|
|
254
255
|
return this.fileResults.get(this.normalize(fileName)) ?? EMPTY_RESULT();
|
|
255
256
|
}
|
|
257
|
+
getAllDynamicOccurrences() {
|
|
258
|
+
const occurrences = [];
|
|
259
|
+
for (const result of this.fileResults.values()) {
|
|
260
|
+
occurrences.push(...result.dynamicOccurrences);
|
|
261
|
+
}
|
|
262
|
+
return occurrences.sort((a, b) => a.fileName.localeCompare(b.fileName) || a.line - b.line);
|
|
263
|
+
}
|
|
256
264
|
}
|
|
257
265
|
exports.EventraEngine = EventraEngine;
|
package/dist/index.js
CHANGED
|
@@ -72,8 +72,9 @@ program
|
|
|
72
72
|
});
|
|
73
73
|
program
|
|
74
74
|
.command("send")
|
|
75
|
-
.
|
|
75
|
+
.option("--trust-endpoint", "approve the current eventra.json endpoint for local use")
|
|
76
|
+
.action(async (opts) => {
|
|
76
77
|
const { send } = await Promise.resolve().then(() => __importStar(require("./cli/send")));
|
|
77
|
-
await send();
|
|
78
|
+
await send({ trustEndpoint: opts.trustEndpoint });
|
|
78
79
|
});
|
|
79
80
|
program.parse();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerConsoleDynamicEventReporterPlugin = registerConsoleDynamicEventReporterPlugin;
|
|
7
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
+
const consoleDynamicEventReporter = {
|
|
9
|
+
name: "console",
|
|
10
|
+
report({ occurrences }) {
|
|
11
|
+
if (occurrences.length === 0) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log(chalk_1.default.blue("\nDynamic event names:"));
|
|
15
|
+
for (const occ of occurrences) {
|
|
16
|
+
const values = occ.resolvedValues.length > 0 ? ` (resolved: ${occ.resolvedValues.join(", ")})` : "";
|
|
17
|
+
console.log(chalk_1.default.yellow(`~ ${occ.fileName}:${occ.line} ${occ.calleeText}(...)${values}`));
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
/** Default reporter: prints unresolved/dynamic event-name call sites to the console (always enabled). */
|
|
22
|
+
function registerConsoleDynamicEventReporterPlugin(registry) {
|
|
23
|
+
registry.registerDynamicEventReporter(consoleDynamicEventReporter);
|
|
24
|
+
}
|
|
@@ -33,11 +33,25 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.isTrustedPluginSpecifier = isTrustedPluginSpecifier;
|
|
36
37
|
exports.createBuiltinPluginRegistry = createBuiltinPluginRegistry;
|
|
37
38
|
exports.createPluginRegistry = createPluginRegistry;
|
|
38
39
|
const external_1 = require("./adapters/external");
|
|
39
40
|
const eventra_sdk_1 = require("./builtins/eventra-sdk");
|
|
41
|
+
const consoleDynamicEventReporter_1 = require("./builtins/consoleDynamicEventReporter");
|
|
40
42
|
const registry_1 = require("./registry");
|
|
43
|
+
/**
|
|
44
|
+
* Only packages published under the `@eventra_dev` npm scope can ever be
|
|
45
|
+
* `import()`-ed as a plugin. `eventra.json` (and its `plugins` array) is meant
|
|
46
|
+
* to be committed to git, so anything else listed there is attacker-editable
|
|
47
|
+
* via a plain PR — restricting to a scope only the maintainers can publish to
|
|
48
|
+
* turns "arbitrary code execution via a one-line JSON edit" into "not possible
|
|
49
|
+
* without also compromising the @eventra_dev npm account."
|
|
50
|
+
*/
|
|
51
|
+
const TRUSTED_PLUGIN_RE = /^@eventra_dev\/cli-plugin-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
52
|
+
function isTrustedPluginSpecifier(spec) {
|
|
53
|
+
return TRUSTED_PLUGIN_RE.test(spec);
|
|
54
|
+
}
|
|
41
55
|
async function resolvePluginExport(mod) {
|
|
42
56
|
let candidate = mod.default ?? mod;
|
|
43
57
|
if (typeof candidate === "function") {
|
|
@@ -76,13 +90,21 @@ async function loadExternalPlugin(registry, spec) {
|
|
|
76
90
|
function createBuiltinPluginRegistry() {
|
|
77
91
|
const registry = new registry_1.PluginRegistry();
|
|
78
92
|
(0, eventra_sdk_1.registerEventraSdkPlugin)(registry);
|
|
93
|
+
(0, consoleDynamicEventReporter_1.registerConsoleDynamicEventReporterPlugin)(registry);
|
|
79
94
|
return registry;
|
|
80
95
|
}
|
|
81
96
|
/** Built-ins + plugins listed in eventra.json `plugins` array. */
|
|
82
97
|
async function createPluginRegistry(config) {
|
|
83
98
|
const registry = createBuiltinPluginRegistry();
|
|
84
99
|
for (const spec of config.plugins ?? []) {
|
|
85
|
-
|
|
100
|
+
const trimmed = spec.trim();
|
|
101
|
+
if (!trimmed)
|
|
102
|
+
continue;
|
|
103
|
+
if (!isTrustedPluginSpecifier(trimmed)) {
|
|
104
|
+
console.warn(`Eventra: skipping plugin "${trimmed}" — only official @eventra_dev/cli-plugin-* packages can be loaded.${pluginLoadHint(trimmed)}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
await loadExternalPlugin(registry, trimmed);
|
|
86
108
|
}
|
|
87
109
|
return registry;
|
|
88
110
|
}
|
package/dist/plugin/registry.js
CHANGED
|
@@ -8,6 +8,8 @@ const path_1 = __importDefault(require("path"));
|
|
|
8
8
|
class PluginRegistry {
|
|
9
9
|
preprocessors = [];
|
|
10
10
|
sinkDetectors = [];
|
|
11
|
+
wrapperDetectors = [];
|
|
12
|
+
dynamicEventReporters = [];
|
|
11
13
|
includePatterns = [];
|
|
12
14
|
sourceToVirtuals = new Map();
|
|
13
15
|
registerFilePreprocessor(preprocessor) {
|
|
@@ -22,6 +24,18 @@ class PluginRegistry {
|
|
|
22
24
|
}
|
|
23
25
|
this.sinkDetectors.push(detector);
|
|
24
26
|
}
|
|
27
|
+
registerWrapperDetector(detector) {
|
|
28
|
+
if (this.wrapperDetectors.some((d) => d.name === detector.name)) {
|
|
29
|
+
throw new Error(`Wrapper detector already registered: ${detector.name}`);
|
|
30
|
+
}
|
|
31
|
+
this.wrapperDetectors.push(detector);
|
|
32
|
+
}
|
|
33
|
+
registerDynamicEventReporter(reporter) {
|
|
34
|
+
if (this.dynamicEventReporters.some((r) => r.name === reporter.name)) {
|
|
35
|
+
throw new Error(`Dynamic event reporter already registered: ${reporter.name}`);
|
|
36
|
+
}
|
|
37
|
+
this.dynamicEventReporters.push(reporter);
|
|
38
|
+
}
|
|
25
39
|
registerIncludePattern(pattern) {
|
|
26
40
|
const trimmed = pattern.trim();
|
|
27
41
|
if (!trimmed)
|
|
@@ -36,6 +50,12 @@ class PluginRegistry {
|
|
|
36
50
|
getSinkDetectors() {
|
|
37
51
|
return this.sinkDetectors;
|
|
38
52
|
}
|
|
53
|
+
getWrapperDetectors() {
|
|
54
|
+
return this.wrapperDetectors;
|
|
55
|
+
}
|
|
56
|
+
getDynamicEventReporters() {
|
|
57
|
+
return this.dynamicEventReporters;
|
|
58
|
+
}
|
|
39
59
|
getFilePreprocessors() {
|
|
40
60
|
return this.preprocessors;
|
|
41
61
|
}
|
|
@@ -71,6 +91,20 @@ class PluginRegistry {
|
|
|
71
91
|
}
|
|
72
92
|
return null;
|
|
73
93
|
}
|
|
94
|
+
detectWrapper(context) {
|
|
95
|
+
for (const detector of this.wrapperDetectors) {
|
|
96
|
+
const wrapper = detector.detect(context);
|
|
97
|
+
if (wrapper) {
|
|
98
|
+
return wrapper;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
async runDynamicEventReporters(occurrences) {
|
|
104
|
+
for (const reporter of this.dynamicEventReporters) {
|
|
105
|
+
await reporter.report({ occurrences });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
74
108
|
normalizePath(fileName) {
|
|
75
109
|
return path_1.default.resolve(fileName).replace(/\\/g, "/");
|
|
76
110
|
}
|