securenow 5.15.0 → 5.16.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/NPM_README.md +43 -9
- package/README.md +13 -4
- package/SKILL-CLI.md +23 -12
- package/cli/auth.js +13 -4
- package/cli/config.js +62 -12
- package/cli/security.js +143 -7
- package/cli/ui.js +1 -0
- package/cli.js +22 -8
- package/docs/ALL-FRAMEWORKS-QUICKSTART.md +21 -3
- package/docs/API-KEYS-GUIDE.md +19 -1
- package/firewall.js +2 -0
- package/package.json +1 -1
package/NPM_README.md
CHANGED
|
@@ -148,7 +148,10 @@ npx securenow login
|
|
|
148
148
|
# Or use a token for CI/headless environments
|
|
149
149
|
npx securenow login --token <YOUR_JWT>
|
|
150
150
|
|
|
151
|
-
#
|
|
151
|
+
# Log in for this project only (per-project credentials)
|
|
152
|
+
npx securenow login --local
|
|
153
|
+
|
|
154
|
+
# Check who you're logged in as (shows auth source)
|
|
152
155
|
npx securenow whoami
|
|
153
156
|
```
|
|
154
157
|
|
|
@@ -241,6 +244,9 @@ npx securenow notifications read-all
|
|
|
241
244
|
```bash
|
|
242
245
|
# View alert rules, channels, and history
|
|
243
246
|
npx securenow alerts rules
|
|
247
|
+
npx securenow alerts rules show <rule-id>
|
|
248
|
+
npx securenow alerts rules update <rule-id> --applications-all
|
|
249
|
+
npx securenow alerts rules update <rule-id> --apps key1,key2
|
|
244
250
|
npx securenow alerts channels
|
|
245
251
|
npx securenow alerts history --limit 20
|
|
246
252
|
```
|
|
@@ -324,12 +330,15 @@ npx securenow config set format json
|
|
|
324
330
|
npx securenow config path
|
|
325
331
|
```
|
|
326
332
|
|
|
327
|
-
Config files are stored in `~/.securenow
|
|
333
|
+
Config files are stored in `~/.securenow/` (global) or `.securenow/` in the project root (per-project):
|
|
328
334
|
|
|
329
335
|
| File | Description |
|
|
330
336
|
|------|-------------|
|
|
331
|
-
|
|
|
332
|
-
|
|
|
337
|
+
| `~/.securenow/config.json` | API URL, default app, output format |
|
|
338
|
+
| `~/.securenow/credentials.json` | Auth token — global (file permissions: 0600) |
|
|
339
|
+
| `.securenow/credentials.json` | Auth token — project-local (use `login --local`) |
|
|
340
|
+
|
|
341
|
+
**Resolution order:** `SECURENOW_TOKEN` env var → project `.securenow/credentials.json` → global `~/.securenow/credentials.json`.
|
|
333
342
|
|
|
334
343
|
### Global Flags
|
|
335
344
|
|
|
@@ -345,14 +354,37 @@ Every command supports these flags:
|
|
|
345
354
|
|
|
346
355
|
| Variable | Description |
|
|
347
356
|
|----------|-------------|
|
|
357
|
+
| `SECURENOW_TOKEN` | JWT token — overrides all file-based credentials |
|
|
348
358
|
| `SECURENOW_API_URL` | Override the API base URL |
|
|
349
359
|
| `SECURENOW_DEBUG` | Show stack traces on errors |
|
|
350
360
|
| `NO_COLOR` | Disable colored output |
|
|
351
361
|
|
|
362
|
+
### Multi-Project Sessions
|
|
363
|
+
|
|
364
|
+
Use `--local` to maintain separate logins per project on the same machine:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
# In project A — log in as user-a@company.com
|
|
368
|
+
cd ~/projects/project-a
|
|
369
|
+
npx securenow login --local
|
|
370
|
+
|
|
371
|
+
# In project B — log in as user-b@company.com
|
|
372
|
+
cd ~/projects/project-b
|
|
373
|
+
npx securenow login --local
|
|
374
|
+
|
|
375
|
+
# Each project uses its own credentials independently
|
|
376
|
+
npx securenow whoami # Shows auth source: project (.securenow/)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
You can also use the `SECURENOW_TOKEN` env var for per-terminal sessions without touching any files.
|
|
380
|
+
|
|
352
381
|
### CI/CD Integration
|
|
353
382
|
|
|
354
383
|
```bash
|
|
355
|
-
# Authenticate with a token in CI
|
|
384
|
+
# Authenticate with a token in CI (env var — no file needed)
|
|
385
|
+
SECURENOW_TOKEN=$MY_SECRET npx securenow issues --json
|
|
386
|
+
|
|
387
|
+
# Or use login with explicit token
|
|
356
388
|
npx securenow login --token $SECURENOW_TOKEN
|
|
357
389
|
|
|
358
390
|
# Use --json for machine-readable output
|
|
@@ -372,9 +404,9 @@ fi
|
|
|
372
404
|
| Category | Command | Description |
|
|
373
405
|
|----------|---------|-------------|
|
|
374
406
|
| **Setup** | `init` | Auto-scaffold instrumentation for your framework |
|
|
375
|
-
| **Auth** | `login` | Authenticate via browser or `--
|
|
376
|
-
| | `logout` | Clear credentials |
|
|
377
|
-
| | `whoami` | Show session info |
|
|
407
|
+
| **Auth** | `login` | Authenticate via browser, `--token`, or `--local` |
|
|
408
|
+
| | `logout` | Clear credentials (`--local` for project only) |
|
|
409
|
+
| | `whoami` | Show session info and auth source |
|
|
378
410
|
| **Apps** | `apps` | List applications |
|
|
379
411
|
| | `apps create <name>` | Create application |
|
|
380
412
|
| | `apps info <id>` | Application details |
|
|
@@ -394,7 +426,9 @@ fi
|
|
|
394
426
|
| | `notifications unread` | Unread count |
|
|
395
427
|
| | `notifications read <id>` | Mark read |
|
|
396
428
|
| | `notifications read-all` | Mark all read |
|
|
397
|
-
| | `alerts rules` |
|
|
429
|
+
| | `alerts rules` | List rules (status, apps, schedule) |
|
|
430
|
+
| | `alerts rules show <id>` | Rule detail |
|
|
431
|
+
| | `alerts rules update <id> --applications-all` / `--apps k1,k2` | Application scope |
|
|
398
432
|
| | `alerts channels` | Alert channels |
|
|
399
433
|
| | `alerts history` | Alert history |
|
|
400
434
|
| **Investigate** | `ip <addr>` | IP intelligence |
|
package/README.md
CHANGED
|
@@ -264,8 +264,10 @@ Most users won't need this — just add `-r securenow/register` to your existing
|
|
|
264
264
|
|---------|-------------|
|
|
265
265
|
| `securenow login` | Log in via browser (opens OAuth flow) |
|
|
266
266
|
| `securenow login --token <TOKEN>` | Log in with a token (for CI/headless) |
|
|
267
|
+
| `securenow login --local` | Log in and save credentials to the current project only |
|
|
267
268
|
| `securenow logout` | Clear stored credentials |
|
|
268
|
-
| `securenow
|
|
269
|
+
| `securenow logout --local` | Clear project-local credentials only |
|
|
270
|
+
| `securenow whoami` | Show current session info (including auth source) |
|
|
269
271
|
|
|
270
272
|
### Applications
|
|
271
273
|
|
|
@@ -300,7 +302,10 @@ Most users won't need this — just add `-r securenow/register` to your existing
|
|
|
300
302
|
| `securenow notifications unread` | Show unread count |
|
|
301
303
|
| `securenow notifications read <id>` | Mark notification as read |
|
|
302
304
|
| `securenow notifications read-all` | Mark all as read |
|
|
303
|
-
| `securenow alerts rules` | List alert rules |
|
|
305
|
+
| `securenow alerts rules` | List alert rules (status, applications, schedule) |
|
|
306
|
+
| `securenow alerts rules show <id>` | Show one rule (includes all-apps vs explicit apps) |
|
|
307
|
+
| `securenow alerts rules update <id> --applications-all` | Set rule to all current & future apps |
|
|
308
|
+
| `securenow alerts rules update <id> --apps k1,k2` | Scope rule to specific app keys |
|
|
304
309
|
| `securenow alerts channels` | List alert channels |
|
|
305
310
|
| `securenow alerts history` | View alert history |
|
|
306
311
|
|
|
@@ -353,15 +358,19 @@ Most users won't need this — just add `-r securenow/register` to your existing
|
|
|
353
358
|
| `--json` | Output as JSON (works on every command) |
|
|
354
359
|
| `--help` | Show help for any command |
|
|
355
360
|
| `--app <key>` | Specify app key (or set default with `config set defaultApp`) |
|
|
361
|
+
| `--local` | Save/clear credentials per-project (login/logout only) |
|
|
356
362
|
|
|
357
363
|
### Configuration
|
|
358
364
|
|
|
359
|
-
Credentials and settings are stored in `~/.securenow
|
|
365
|
+
Credentials and settings are stored in `~/.securenow/` (global) or `.securenow/` (per-project):
|
|
360
366
|
|
|
361
367
|
| File | Purpose |
|
|
362
368
|
|------|---------|
|
|
363
369
|
| `~/.securenow/config.json` | API URL, default app, preferences |
|
|
364
|
-
| `~/.securenow/credentials.json` | Auth token (restricted permissions) |
|
|
370
|
+
| `~/.securenow/credentials.json` | Auth token — global (restricted permissions) |
|
|
371
|
+
| `.securenow/credentials.json` | Auth token — project-local (use `login --local`) |
|
|
372
|
+
|
|
373
|
+
**Credential resolution order:** `SECURENOW_TOKEN` env var → project `.securenow/credentials.json` → global `~/.securenow/credentials.json`.
|
|
365
374
|
|
|
366
375
|
Override the API URL with `securenow config set apiUrl <url>` or the `SECURENOW_API_URL` environment variable.
|
|
367
376
|
|
package/SKILL-CLI.md
CHANGED
|
@@ -18,9 +18,12 @@ npx securenow <command>
|
|
|
18
18
|
```bash
|
|
19
19
|
securenow login # opens browser OAuth; stores JWT in ~/.securenow/credentials.json
|
|
20
20
|
securenow login --token <JWT> # headless / CI login (get token from dashboard Settings)
|
|
21
|
-
securenow
|
|
21
|
+
securenow login --local # save credentials to this project only (.securenow/)
|
|
22
|
+
securenow whoami # verify session (shows auth source)
|
|
22
23
|
```
|
|
23
24
|
|
|
25
|
+
**Per-project credentials:** Use `--local` to keep separate logins in different project directories on the same machine. Credentials resolve in order: `SECURENOW_TOKEN` env var → project `.securenow/credentials.json` → global `~/.securenow/credentials.json`.
|
|
26
|
+
|
|
24
27
|
### Integrate With Your App
|
|
25
28
|
|
|
26
29
|
The CLI can also instrument any Node.js app at launch — no code changes:
|
|
@@ -47,22 +50,25 @@ Save this file as `.cursor/skills/securenow-cli/SKILL.md` in your project. Your
|
|
|
47
50
|
|
|
48
51
|
## Configuration
|
|
49
52
|
|
|
50
|
-
Config lives in `~/.securenow
|
|
53
|
+
Config lives in `~/.securenow/` (global) and optionally `.securenow/` (per-project):
|
|
51
54
|
|
|
52
55
|
| File | Content |
|
|
53
56
|
|------|---------|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
57
|
+
| `~/.securenow/config.json` | `apiUrl`, `appUrl`, `defaultApp`, `output` |
|
|
58
|
+
| `~/.securenow/credentials.json` | `token`, `email`, `expiresAt` (global) |
|
|
59
|
+
| `.securenow/credentials.json` | `token`, `email`, `expiresAt` (project-local, use `login --local`) |
|
|
60
|
+
|
|
61
|
+
**Credential resolution order:** `SECURENOW_TOKEN` env var → `.securenow/credentials.json` (project) → `~/.securenow/credentials.json` (global).
|
|
56
62
|
|
|
57
63
|
```bash
|
|
58
64
|
securenow config set apiUrl https://api.securenow.ai
|
|
59
65
|
securenow config set defaultApp my-app-key
|
|
60
66
|
securenow config get # show all
|
|
61
67
|
securenow config get defaultApp # show one
|
|
62
|
-
securenow config path # print file paths
|
|
68
|
+
securenow config path # print file paths + active auth source
|
|
63
69
|
```
|
|
64
70
|
|
|
65
|
-
Environment overrides: `SECURENOW_API_URL`, `SECURENOW_APP_URL`, `SECURENOW_APP` (default app key).
|
|
71
|
+
Environment overrides: `SECURENOW_TOKEN` (JWT), `SECURENOW_API_URL`, `SECURENOW_APP_URL`, `SECURENOW_APP` (default app key).
|
|
66
72
|
|
|
67
73
|
## Global Flags
|
|
68
74
|
|
|
@@ -94,10 +100,12 @@ Spawns `node --require securenow/register [--import otel/hook.mjs] <script>`. ES
|
|
|
94
100
|
### Authentication
|
|
95
101
|
|
|
96
102
|
```bash
|
|
97
|
-
securenow login # browser-based OAuth (
|
|
103
|
+
securenow login # browser-based OAuth (stores in global ~/.securenow/)
|
|
98
104
|
securenow login --token <JWT> # headless / CI login
|
|
99
|
-
securenow
|
|
100
|
-
securenow
|
|
105
|
+
securenow login --local # save credentials to project .securenow/ (per-project session)
|
|
106
|
+
securenow logout # clear active credentials (local if present, else global)
|
|
107
|
+
securenow logout --local # clear project-local credentials only
|
|
108
|
+
securenow whoami # show email, user ID, API URL, auth source, expiry, default app
|
|
101
109
|
```
|
|
102
110
|
|
|
103
111
|
### Applications
|
|
@@ -174,7 +182,10 @@ securenow notifications unread # unread count
|
|
|
174
182
|
|
|
175
183
|
```bash
|
|
176
184
|
securenow alerts # list alert rules (default)
|
|
177
|
-
securenow alerts rules # list alert rules
|
|
185
|
+
securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
|
|
186
|
+
securenow alerts rules show <id> # one rule; JSON: --json
|
|
187
|
+
securenow alerts rules update <id> --applications-all # all current & future apps
|
|
188
|
+
securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
|
|
178
189
|
securenow alerts channels # list alert channels (Slack, email, etc.)
|
|
179
190
|
securenow alerts history [--limit N] # past triggered alerts
|
|
180
191
|
```
|
|
@@ -386,8 +397,8 @@ All commands support `--json` for structured output. When piping to other tools
|
|
|
386
397
|
|
|
387
398
|
| Exit code / Error | Meaning | Recovery |
|
|
388
399
|
|------------------|---------|----------|
|
|
389
|
-
| `Session expired` | JWT expired | `securenow login` |
|
|
390
|
-
| `Not logged in` | No token
|
|
400
|
+
| `Session expired` | JWT expired | `securenow login` (or `login --local`) |
|
|
401
|
+
| `Not logged in` | No token found | `securenow login` or set `SECURENOW_TOKEN` env var |
|
|
391
402
|
| `Access denied (403)` | Insufficient plan or permissions | Upgrade plan or check user role |
|
|
392
403
|
| `Cannot connect` | API unreachable | Check `SECURENOW_API_URL` or network |
|
|
393
404
|
| `Unknown command` | Typo or unrecognized command | `securenow help` |
|
package/cli/auth.js
CHANGED
|
@@ -120,6 +120,8 @@ async function loginWithToken(token) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
async function login(args, flags) {
|
|
123
|
+
const local = !!flags.local;
|
|
124
|
+
|
|
123
125
|
if (flags.token) {
|
|
124
126
|
const token = flags.token;
|
|
125
127
|
await loginWithToken(token);
|
|
@@ -127,9 +129,11 @@ async function login(args, flags) {
|
|
|
127
129
|
const email = payload?.email || 'unknown';
|
|
128
130
|
const exp = payload?.exp ? payload.exp * 1000 : null;
|
|
129
131
|
|
|
130
|
-
config.setAuth(token, email, exp);
|
|
132
|
+
config.setAuth(token, email, exp, { local });
|
|
133
|
+
if (local) config.ensureLocalGitignore();
|
|
131
134
|
console.log('');
|
|
132
135
|
ui.success(`Logged in as ${ui.c.bold(email)}`);
|
|
136
|
+
if (local) ui.info('Credentials saved to project .securenow/ (local)');
|
|
133
137
|
if (exp) {
|
|
134
138
|
const days = Math.ceil((exp - Date.now()) / (1000 * 60 * 60 * 24));
|
|
135
139
|
ui.info(`Session expires in ${days} days`);
|
|
@@ -143,9 +147,11 @@ async function login(args, flags) {
|
|
|
143
147
|
const email = payload?.email || 'unknown';
|
|
144
148
|
const exp = payload?.exp ? payload.exp * 1000 : null;
|
|
145
149
|
|
|
146
|
-
config.setAuth(token, email, exp);
|
|
150
|
+
config.setAuth(token, email, exp, { local });
|
|
151
|
+
if (local) config.ensureLocalGitignore();
|
|
147
152
|
console.log('');
|
|
148
153
|
ui.success(`Logged in as ${ui.c.bold(email)}`);
|
|
154
|
+
if (local) ui.info('Credentials saved to project .securenow/ (local)');
|
|
149
155
|
if (exp) {
|
|
150
156
|
const days = Math.ceil((exp - Date.now()) / (1000 * 60 * 60 * 24));
|
|
151
157
|
ui.info(`Session expires in ${days} days`);
|
|
@@ -165,14 +171,16 @@ async function login(args, flags) {
|
|
|
165
171
|
}
|
|
166
172
|
}
|
|
167
173
|
|
|
168
|
-
async function logout() {
|
|
174
|
+
async function logout(args, flags) {
|
|
175
|
+
const local = flags ? flags.local : undefined;
|
|
169
176
|
const creds = config.loadCredentials();
|
|
170
|
-
config.clearCredentials();
|
|
177
|
+
config.clearCredentials({ local });
|
|
171
178
|
if (creds.email) {
|
|
172
179
|
ui.success(`Logged out from ${ui.c.bold(creds.email)}`);
|
|
173
180
|
} else {
|
|
174
181
|
ui.success('Logged out');
|
|
175
182
|
}
|
|
183
|
+
if (local) ui.info('Cleared project-local credentials');
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
async function whoami() {
|
|
@@ -192,6 +200,7 @@ async function whoami() {
|
|
|
192
200
|
['Email', creds.email || payload?.email || 'unknown'],
|
|
193
201
|
['User ID', payload?.sub || 'unknown'],
|
|
194
202
|
['API', config.getApiUrl()],
|
|
203
|
+
['Auth Source', config.getAuthSource()],
|
|
195
204
|
];
|
|
196
205
|
if (creds.expiresAt) {
|
|
197
206
|
const days = Math.ceil((creds.expiresAt - Date.now()) / (1000 * 60 * 60 * 24));
|
package/cli/config.js
CHANGED
|
@@ -8,6 +8,10 @@ const CONFIG_DIR = path.join(os.homedir(), '.securenow');
|
|
|
8
8
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
9
|
const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
|
|
10
10
|
|
|
11
|
+
const LOCAL_CONFIG_DIR = path.join(process.cwd(), '.securenow');
|
|
12
|
+
const LOCAL_CONFIG_FILE = path.join(LOCAL_CONFIG_DIR, 'config.json');
|
|
13
|
+
const LOCAL_CREDENTIALS_FILE = path.join(LOCAL_CONFIG_DIR, 'credentials.json');
|
|
14
|
+
|
|
11
15
|
const DEFAULTS = {
|
|
12
16
|
apiUrl: 'https://api.securenow.ai',
|
|
13
17
|
appUrl: 'https://app.securenow.ai',
|
|
@@ -15,9 +19,9 @@ const DEFAULTS = {
|
|
|
15
19
|
output: 'table',
|
|
16
20
|
};
|
|
17
21
|
|
|
18
|
-
function ensureDir() {
|
|
19
|
-
if (!fs.existsSync(CONFIG_DIR)) {
|
|
20
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
22
|
+
function ensureDir(dir) {
|
|
23
|
+
if (!fs.existsSync(dir || CONFIG_DIR)) {
|
|
24
|
+
fs.mkdirSync(dir || CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -30,7 +34,7 @@ function loadJSON(filepath) {
|
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
function saveJSON(filepath, data) {
|
|
33
|
-
ensureDir();
|
|
37
|
+
ensureDir(path.dirname(filepath));
|
|
34
38
|
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
35
39
|
if (process.platform === 'win32') {
|
|
36
40
|
try {
|
|
@@ -40,8 +44,25 @@ function saveJSON(filepath, data) {
|
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
function hasLocalCredentials() {
|
|
48
|
+
return fs.existsSync(LOCAL_CREDENTIALS_FILE);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveCredentialsFile() {
|
|
52
|
+
if (fs.existsSync(LOCAL_CREDENTIALS_FILE)) return LOCAL_CREDENTIALS_FILE;
|
|
53
|
+
return CREDENTIALS_FILE;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getAuthSource() {
|
|
57
|
+
if (process.env.SECURENOW_TOKEN) return 'env (SECURENOW_TOKEN)';
|
|
58
|
+
if (fs.existsSync(LOCAL_CREDENTIALS_FILE)) return 'project (.securenow/)';
|
|
59
|
+
return 'global (~/.securenow/)';
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
function loadConfig() {
|
|
44
|
-
|
|
63
|
+
const global = loadJSON(CONFIG_FILE);
|
|
64
|
+
const local = fs.existsSync(LOCAL_CONFIG_FILE) ? loadJSON(LOCAL_CONFIG_FILE) : {};
|
|
65
|
+
return { ...DEFAULTS, ...global, ...local };
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
function saveConfig(config) {
|
|
@@ -59,20 +80,29 @@ function setConfigValue(key, value) {
|
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
function loadCredentials() {
|
|
62
|
-
return loadJSON(
|
|
83
|
+
return loadJSON(resolveCredentialsFile());
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
function saveCredentials(creds) {
|
|
66
|
-
|
|
86
|
+
function saveCredentials(creds, { local = false } = {}) {
|
|
87
|
+
const targetFile = local ? LOCAL_CREDENTIALS_FILE : CREDENTIALS_FILE;
|
|
88
|
+
saveJSON(targetFile, creds);
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
function clearCredentials() {
|
|
91
|
+
function clearCredentials({ local } = {}) {
|
|
70
92
|
try {
|
|
71
|
-
|
|
93
|
+
if (local === true) {
|
|
94
|
+
fs.unlinkSync(LOCAL_CREDENTIALS_FILE);
|
|
95
|
+
} else if (local === false || !hasLocalCredentials()) {
|
|
96
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
97
|
+
} else {
|
|
98
|
+
fs.unlinkSync(LOCAL_CREDENTIALS_FILE);
|
|
99
|
+
}
|
|
72
100
|
} catch {}
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
function getToken() {
|
|
104
|
+
if (process.env.SECURENOW_TOKEN) return process.env.SECURENOW_TOKEN;
|
|
105
|
+
|
|
76
106
|
const creds = loadCredentials();
|
|
77
107
|
if (!creds.token) return null;
|
|
78
108
|
|
|
@@ -82,8 +112,23 @@ function getToken() {
|
|
|
82
112
|
return creds.token;
|
|
83
113
|
}
|
|
84
114
|
|
|
85
|
-
function setAuth(token, email, expiresAt) {
|
|
86
|
-
saveCredentials({ token, email, expiresAt });
|
|
115
|
+
function setAuth(token, email, expiresAt, { local = false } = {}) {
|
|
116
|
+
saveCredentials({ token, email, expiresAt }, { local });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ensureLocalGitignore() {
|
|
120
|
+
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
121
|
+
const entry = '.securenow/';
|
|
122
|
+
try {
|
|
123
|
+
if (fs.existsSync(gitignorePath)) {
|
|
124
|
+
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
125
|
+
if (!content.split('\n').some(line => line.trim() === entry)) {
|
|
126
|
+
fs.appendFileSync(gitignorePath, `\n# SecureNow local credentials\n${entry}\n`);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
fs.writeFileSync(gitignorePath, `# SecureNow local credentials\n${entry}\n`);
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
87
132
|
}
|
|
88
133
|
|
|
89
134
|
function getApiUrl() {
|
|
@@ -102,6 +147,8 @@ module.exports = {
|
|
|
102
147
|
CONFIG_DIR,
|
|
103
148
|
CONFIG_FILE,
|
|
104
149
|
CREDENTIALS_FILE,
|
|
150
|
+
LOCAL_CONFIG_DIR,
|
|
151
|
+
LOCAL_CREDENTIALS_FILE,
|
|
105
152
|
loadConfig,
|
|
106
153
|
saveConfig,
|
|
107
154
|
getConfigValue,
|
|
@@ -111,6 +158,9 @@ module.exports = {
|
|
|
111
158
|
clearCredentials,
|
|
112
159
|
getToken,
|
|
113
160
|
setAuth,
|
|
161
|
+
getAuthSource,
|
|
162
|
+
hasLocalCredentials,
|
|
163
|
+
ensureLocalGitignore,
|
|
114
164
|
getApiUrl,
|
|
115
165
|
getAppUrl,
|
|
116
166
|
getDefaultApp,
|
package/cli/security.js
CHANGED
|
@@ -10,6 +10,39 @@ function resolveApp(flags) {
|
|
|
10
10
|
|
|
11
11
|
// ── Alert Rules ──
|
|
12
12
|
|
|
13
|
+
function formatRuleApplicationsCell(rule) {
|
|
14
|
+
if (rule.applicationsAll) {
|
|
15
|
+
return ui.c.cyan('all apps');
|
|
16
|
+
}
|
|
17
|
+
const keys = rule.applications || [];
|
|
18
|
+
if (keys.length === 0) return ui.c.dim('—');
|
|
19
|
+
const joined = keys.join(', ');
|
|
20
|
+
return joined.length > 48 ? `${joined.slice(0, 45)}…` : joined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ruleStatusBadge(rule) {
|
|
24
|
+
const st = rule.status || (rule.enabled !== false ? 'Active' : 'Disabled');
|
|
25
|
+
if (st === 'Active') return ui.statusBadge('active');
|
|
26
|
+
if (st === 'Disabled') return ui.statusBadge('disabled');
|
|
27
|
+
if (st === 'Paused') return ui.statusBadge('paused');
|
|
28
|
+
return st;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Dispatch: list | show <id> | update <id> ... */
|
|
32
|
+
async function alertRulesRoute(args, flags) {
|
|
33
|
+
const sub = args[0];
|
|
34
|
+
if (sub === 'show') {
|
|
35
|
+
return alertRuleShow(args.slice(1), flags);
|
|
36
|
+
}
|
|
37
|
+
if (sub === 'update') {
|
|
38
|
+
return alertRuleUpdate(args.slice(1), flags);
|
|
39
|
+
}
|
|
40
|
+
if (sub === 'list') {
|
|
41
|
+
return alertRulesList(args.slice(1), flags);
|
|
42
|
+
}
|
|
43
|
+
return alertRulesList(args, flags);
|
|
44
|
+
}
|
|
45
|
+
|
|
13
46
|
async function alertRulesList(args, flags) {
|
|
14
47
|
requireAuth();
|
|
15
48
|
const s = ui.spinner('Fetching alert rules');
|
|
@@ -21,15 +54,16 @@ async function alertRulesList(args, flags) {
|
|
|
21
54
|
if (flags.json) { ui.json(rules); return; }
|
|
22
55
|
|
|
23
56
|
console.log('');
|
|
24
|
-
const rows = rules.map(r => [
|
|
57
|
+
const rows = rules.map((r) => [
|
|
25
58
|
ui.c.dim(ui.truncate(r._id, 12)),
|
|
26
|
-
r.name ||
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
r.
|
|
30
|
-
r.serviceName || ui.c.dim('all'),
|
|
59
|
+
r.name || '—',
|
|
60
|
+
ruleStatusBadge(r),
|
|
61
|
+
formatRuleApplicationsCell(r),
|
|
62
|
+
r.schedule?.enabled === false ? ui.c.dim('off') : (r.schedule?.description || r.schedule?.cronExpression || '—'),
|
|
31
63
|
]);
|
|
32
|
-
ui.table(['ID', 'Name', 'Status', '
|
|
64
|
+
ui.table(['ID', 'Name', 'Status', 'Applications', 'Schedule'], rows);
|
|
65
|
+
console.log('');
|
|
66
|
+
console.log(ui.c.dim(' Applications: "all apps" = all current & future active apps. show <id> · update <id> --applications-all · --apps k1,k2'));
|
|
33
67
|
console.log('');
|
|
34
68
|
} catch (err) {
|
|
35
69
|
s.fail('Failed to fetch alert rules');
|
|
@@ -37,6 +71,105 @@ async function alertRulesList(args, flags) {
|
|
|
37
71
|
}
|
|
38
72
|
}
|
|
39
73
|
|
|
74
|
+
async function alertRuleShow(args, flags) {
|
|
75
|
+
requireAuth();
|
|
76
|
+
const id = args[0];
|
|
77
|
+
if (!id) {
|
|
78
|
+
ui.error('Usage: securenow alerts rules show <rule-id>');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const s = ui.spinner('Fetching alert rule');
|
|
82
|
+
try {
|
|
83
|
+
const data = await api.get(`/alert-rules/${id}`);
|
|
84
|
+
const r = data.alertRule;
|
|
85
|
+
s.stop('');
|
|
86
|
+
|
|
87
|
+
if (flags.json) {
|
|
88
|
+
ui.json(r);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('');
|
|
93
|
+
ui.heading(r.name || 'Alert rule');
|
|
94
|
+
console.log('');
|
|
95
|
+
const appLine = r.applicationsAll
|
|
96
|
+
? 'All applications (current & future)'
|
|
97
|
+
: (r.applications && r.applications.length > 0 ? r.applications.join(', ') : '—');
|
|
98
|
+
ui.keyValue([
|
|
99
|
+
['ID', r._id || r.id || id],
|
|
100
|
+
['Status', r.status || '—'],
|
|
101
|
+
['System rule', r.isSystem ? 'yes' : 'no'],
|
|
102
|
+
['Applications', appLine],
|
|
103
|
+
['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '—')],
|
|
104
|
+
['Throttle', r.throttle?.enabled ? `${r.throttle.minutes} min` : 'off'],
|
|
105
|
+
['Query', r.queryMappingId?.name || '—'],
|
|
106
|
+
]);
|
|
107
|
+
console.log('');
|
|
108
|
+
} catch (err) {
|
|
109
|
+
s.fail('Failed to fetch alert rule');
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function alertRuleUpdate(args, flags) {
|
|
115
|
+
requireAuth();
|
|
116
|
+
const id = args[0];
|
|
117
|
+
if (!id) {
|
|
118
|
+
ui.error('Usage: securenow alerts rules update <rule-id> (--applications-all | --apps <k1,k2>)');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const hasAll =
|
|
123
|
+
flags['applications-all'] === true ||
|
|
124
|
+
flags['applications-all'] === 'true';
|
|
125
|
+
const hasNoAll =
|
|
126
|
+
flags['no-applications-all'] === true ||
|
|
127
|
+
flags['no-applications-all'] === 'true';
|
|
128
|
+
const appsStr = flags.apps;
|
|
129
|
+
|
|
130
|
+
if (hasAll && hasNoAll) {
|
|
131
|
+
ui.error('Use only one of --applications-all or --no-applications-all');
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
if (hasAll && appsStr) {
|
|
135
|
+
ui.error('Cannot combine --applications-all with --apps');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const body = {};
|
|
140
|
+
if (hasAll) {
|
|
141
|
+
body.applicationsAll = true;
|
|
142
|
+
body.applications = [];
|
|
143
|
+
} else if (hasNoAll || appsStr) {
|
|
144
|
+
body.applicationsAll = false;
|
|
145
|
+
if (!appsStr) {
|
|
146
|
+
ui.error('Pass --apps key1,key2 when using --no-applications-all, or omit --no-applications-all and use --apps only');
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
body.applications = String(appsStr)
|
|
150
|
+
.split(',')
|
|
151
|
+
.map((x) => x.trim())
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
if (body.applications.length === 0) {
|
|
154
|
+
ui.error('No application keys parsed from --apps');
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
ui.error('Nothing to update. Use --applications-all or --apps key1,key2');
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const s = ui.spinner('Updating alert rule');
|
|
163
|
+
try {
|
|
164
|
+
await api.put(`/alert-rules/${id}`, body);
|
|
165
|
+
s.stop('Updated');
|
|
166
|
+
ui.success('Alert rule updated');
|
|
167
|
+
} catch (err) {
|
|
168
|
+
s.fail('Failed to update alert rule');
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
40
173
|
// ── Alert Channels ──
|
|
41
174
|
|
|
42
175
|
async function alertChannelsList(args, flags) {
|
|
@@ -901,7 +1034,10 @@ async function analytics(args, flags) {
|
|
|
901
1034
|
}
|
|
902
1035
|
|
|
903
1036
|
module.exports = {
|
|
1037
|
+
alertRulesRoute,
|
|
904
1038
|
alertRulesList,
|
|
1039
|
+
alertRuleShow,
|
|
1040
|
+
alertRuleUpdate,
|
|
905
1041
|
alertChannelsList,
|
|
906
1042
|
alertHistoryList,
|
|
907
1043
|
blocklistList,
|
package/cli/ui.js
CHANGED
|
@@ -302,6 +302,7 @@ function statusBadge(status) {
|
|
|
302
302
|
trusted: c.green('■ trusted'),
|
|
303
303
|
enabled: c.green('● enabled'),
|
|
304
304
|
disabled: c.dim('○ disabled'),
|
|
305
|
+
paused: c.yellow('◆ paused'),
|
|
305
306
|
critical: c.red('▲ critical'),
|
|
306
307
|
high: c.red('▲ high'),
|
|
307
308
|
medium: c.yellow('▲ medium'),
|
package/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const ui = require('./cli/ui');
|
|
@@ -47,14 +47,15 @@ const COMMANDS = {
|
|
|
47
47
|
},
|
|
48
48
|
login: {
|
|
49
49
|
desc: 'Authenticate with SecureNow',
|
|
50
|
-
usage: 'securenow login [--token <TOKEN>]',
|
|
51
|
-
flags: { token: 'Authenticate with a token directly' },
|
|
50
|
+
usage: 'securenow login [--token <TOKEN>] [--local]',
|
|
51
|
+
flags: { token: 'Authenticate with a token directly', local: 'Save credentials to this project only (.securenow/)' },
|
|
52
52
|
run: (a, f) => require('./cli/auth').login(a, f),
|
|
53
53
|
},
|
|
54
54
|
logout: {
|
|
55
55
|
desc: 'Clear stored credentials',
|
|
56
|
-
usage: 'securenow logout',
|
|
57
|
-
|
|
56
|
+
usage: 'securenow logout [--local]',
|
|
57
|
+
flags: { local: 'Clear project-local credentials only' },
|
|
58
|
+
run: (a, f) => require('./cli/auth').logout(a, f),
|
|
58
59
|
},
|
|
59
60
|
whoami: {
|
|
60
61
|
desc: 'Show current session info',
|
|
@@ -119,7 +120,16 @@ const COMMANDS = {
|
|
|
119
120
|
desc: 'Manage alerting',
|
|
120
121
|
usage: 'securenow alerts <subcommand> [options]',
|
|
121
122
|
sub: {
|
|
122
|
-
rules: {
|
|
123
|
+
rules: {
|
|
124
|
+
desc: 'List, show, or update alert rules',
|
|
125
|
+
flags: {
|
|
126
|
+
json: 'Output as JSON',
|
|
127
|
+
'applications-all': 'With update: scope rule to all apps',
|
|
128
|
+
'no-applications-all': 'With update: scope to explicit --apps list',
|
|
129
|
+
apps: 'Comma-separated app keys (with update)',
|
|
130
|
+
},
|
|
131
|
+
run: (a, f) => require('./cli/security').alertRulesRoute(a, f),
|
|
132
|
+
},
|
|
123
133
|
channels: { desc: 'List alert channels', run: (a, f) => require('./cli/security').alertChannelsList(a, f) },
|
|
124
134
|
history: { desc: 'View alert history', flags: { limit: 'Max results' }, run: (a, f) => require('./cli/security').alertHistoryList(a, f) },
|
|
125
135
|
},
|
|
@@ -267,8 +277,12 @@ const COMMANDS = {
|
|
|
267
277
|
desc: 'Show config file path',
|
|
268
278
|
run: () => {
|
|
269
279
|
const conf = require('./cli/config');
|
|
270
|
-
console.log(`Config:
|
|
271
|
-
console.log(`Credentials:
|
|
280
|
+
console.log(`Config: ${conf.CONFIG_FILE}`);
|
|
281
|
+
console.log(`Credentials: ${conf.CREDENTIALS_FILE}`);
|
|
282
|
+
if (conf.hasLocalCredentials()) {
|
|
283
|
+
console.log(`Local creds: ${conf.LOCAL_CREDENTIALS_FILE} ${require('./cli/ui').c.green('(active)')}`);
|
|
284
|
+
}
|
|
285
|
+
console.log(`Auth source: ${conf.getAuthSource()}`);
|
|
272
286
|
},
|
|
273
287
|
},
|
|
274
288
|
},
|
|
@@ -764,10 +764,14 @@ The SecureNow CLI is your terminal command center. Below is every command organi
|
|
|
764
764
|
|
|
765
765
|
| Command | What It Does |
|
|
766
766
|
|---------|-------------|
|
|
767
|
-
| `securenow login` | Opens browser to authenticate |
|
|
767
|
+
| `securenow login` | Opens browser to authenticate (global session) |
|
|
768
768
|
| `securenow login --token <T>` | Authenticate with a token (for CI/CD or headless servers) |
|
|
769
|
+
| `securenow login --local` | Save credentials to this project only (per-project session) |
|
|
769
770
|
| `securenow logout` | Clear stored credentials |
|
|
770
|
-
| `securenow
|
|
771
|
+
| `securenow logout --local` | Clear project-local credentials only |
|
|
772
|
+
| `securenow whoami` | Show current session (email, API URL, auth source, expiry, default app) |
|
|
773
|
+
|
|
774
|
+
**Per-project credentials:** Use `--local` to maintain separate logins for different projects on the same machine. The CLI resolves credentials in order: `SECURENOW_TOKEN` env var → project `.securenow/credentials.json` → global `~/.securenow/credentials.json`.
|
|
771
775
|
|
|
772
776
|
### App Management
|
|
773
777
|
|
|
@@ -805,6 +809,9 @@ The SecureNow CLI is your terminal command center. Below is every command organi
|
|
|
805
809
|
| `securenow issues show <id>` | Full issue detail + AI analysis |
|
|
806
810
|
| `securenow issues resolve <id>` | Mark issue as resolved |
|
|
807
811
|
| `securenow alerts rules` | List alert rules |
|
|
812
|
+
| `securenow alerts rules show <id>` | One rule (all-apps vs explicit keys) |
|
|
813
|
+
| `securenow alerts rules update <id> --applications-all` | All current & future apps |
|
|
814
|
+
| `securenow alerts rules update <id> --apps k1,k2` | Explicit app keys only |
|
|
808
815
|
| `securenow alerts channels` | List alert channels (email, webhook, Slack) |
|
|
809
816
|
| `securenow alerts history --limit 50` | View past triggered alerts |
|
|
810
817
|
| `securenow notifications` | List notifications |
|
|
@@ -1171,6 +1178,11 @@ Configure alert rules and channels from the [dashboard](https://app.securenow.ai
|
|
|
1171
1178
|
# List your alert rules
|
|
1172
1179
|
npx securenow alerts rules
|
|
1173
1180
|
|
|
1181
|
+
# Show one rule / set application scope (all apps vs explicit keys)
|
|
1182
|
+
npx securenow alerts rules show <rule-id>
|
|
1183
|
+
npx securenow alerts rules update <rule-id> --applications-all
|
|
1184
|
+
npx securenow alerts rules update <rule-id> --apps key1,key2
|
|
1185
|
+
|
|
1174
1186
|
# List alert channels (email, Slack, webhook)
|
|
1175
1187
|
npx securenow alerts channels
|
|
1176
1188
|
|
|
@@ -1305,9 +1317,15 @@ Or re-authenticate with a token:
|
|
|
1305
1317
|
npx securenow login --token <YOUR_TOKEN>
|
|
1306
1318
|
```
|
|
1307
1319
|
|
|
1320
|
+
Or set the env var directly:
|
|
1321
|
+
|
|
1322
|
+
```bash
|
|
1323
|
+
SECURENOW_TOKEN=<YOUR_JWT> npx securenow whoami
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1308
1326
|
### CLI says "Session expired"
|
|
1309
1327
|
|
|
1310
|
-
Tokens expire after a set period. Re-run `securenow login` to get a fresh session.
|
|
1328
|
+
Tokens expire after a set period. Re-run `securenow login` to get a fresh session. Use `securenow whoami` to check which credential source is active.
|
|
1311
1329
|
|
|
1312
1330
|
---
|
|
1313
1331
|
|
package/docs/API-KEYS-GUIDE.md
CHANGED
|
@@ -87,6 +87,8 @@ Restrict an API key to specific applications. When set, the key can only access
|
|
|
87
87
|
|
|
88
88
|
Leave empty to allow access to all applications on your account.
|
|
89
89
|
|
|
90
|
+
**Alert rules:** Keys that are scoped to specific applications **cannot** create or update alert rules with **`applicationsAll: true`** (“all applications”). Use explicit app keys on each rule instead. Unscoped keys may use all-apps mode.
|
|
91
|
+
|
|
90
92
|
---
|
|
91
93
|
|
|
92
94
|
## IP Allowlisting
|
|
@@ -126,7 +128,7 @@ The firewall SDK reads this automatically on startup.
|
|
|
126
128
|
### In CI/CD
|
|
127
129
|
|
|
128
130
|
```yaml
|
|
129
|
-
# GitHub Actions example
|
|
131
|
+
# GitHub Actions example — SDK firewall key
|
|
130
132
|
env:
|
|
131
133
|
SECURENOW_API_KEY: ${{ secrets.SECURENOW_API_KEY }}
|
|
132
134
|
|
|
@@ -137,6 +139,22 @@ steps:
|
|
|
137
139
|
echo "$ISSUES" | jq '.issues | length'
|
|
138
140
|
```
|
|
139
141
|
|
|
142
|
+
### CLI Authentication in CI/CD
|
|
143
|
+
|
|
144
|
+
For CLI commands in CI, use the `SECURENOW_TOKEN` env var to skip file-based login:
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
# GitHub Actions example — CLI auth via env var
|
|
148
|
+
env:
|
|
149
|
+
SECURENOW_TOKEN: ${{ secrets.SECURENOW_CLI_TOKEN }}
|
|
150
|
+
|
|
151
|
+
steps:
|
|
152
|
+
- run: npx securenow issues --json --status open
|
|
153
|
+
- run: npx securenow forensics "critical attacks in last 24h" --json
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The `SECURENOW_TOKEN` env var takes priority over any stored credentials.
|
|
157
|
+
|
|
140
158
|
---
|
|
141
159
|
|
|
142
160
|
## Key Management
|
package/firewall.js
CHANGED
|
@@ -395,6 +395,7 @@ p{font-size:.9rem;line-height:1.7;color:#a1a1aa;margin-bottom:.5rem}
|
|
|
395
395
|
.contact a{color:#f87171;text-decoration:none;font-weight:500}
|
|
396
396
|
.contact a:hover{text-decoration:underline}
|
|
397
397
|
.footer{margin-top:2rem;font-size:.7rem;color:#3f3f46}
|
|
398
|
+
.powered{margin-top:1.5rem;font-size:.75rem;color:#52525b}.powered a{color:#a1a1aa;text-decoration:none;font-weight:600;transition:color .2s}.powered a:hover{color:#f87171;text-decoration:underline}
|
|
398
399
|
</style>
|
|
399
400
|
</head>
|
|
400
401
|
<body>
|
|
@@ -408,6 +409,7 @@ p{font-size:.9rem;line-height:1.7;color:#a1a1aa;margin-bottom:.5rem}
|
|
|
408
409
|
<div class="divider"></div>
|
|
409
410
|
<p class="contact">If you believe this is a mistake, please contact us at<br><a href="mailto:contact@securenow.ai?subject=Blocked%20IP%20Appeal%20-%20${encodeURIComponent(maskedIp)}&body=IP:%20${encodeURIComponent(maskedIp)}%0ATimestamp:%20${encodeURIComponent(new Date().toISOString())}%0A%0APlease%20describe%20why%20you%20believe%20this%20block%20is%20incorrect:">contact@securenow.ai</a><br>Include your IP address and the time of this incident.</p>
|
|
410
411
|
<p class="footer">Ref: ${maskedIp} — ${new Date().toISOString()} — HTTP 403</p>
|
|
412
|
+
<p class="powered">Protected by <a href="https://securenow.ai" rel="dofollow" target="_blank">SecureNow</a></p>
|
|
411
413
|
</div>
|
|
412
414
|
</body>
|
|
413
415
|
</html>`;
|
package/package.json
CHANGED