iamsecure 0.1.0 → 0.1.3
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 +49 -2
- package/bin/iamsecure.js +6 -1
- package/package.json +1 -1
- package/src/code/engine.js +13 -2
- package/src/report/terminal.js +4 -1
- package/src/scan.js +18 -20
- package/src/ui/index.html +3 -2
- package/src/ui/server.js +91 -8
package/README.md
CHANGED
|
@@ -1,14 +1,61 @@
|
|
|
1
1
|
# iamsecure
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/iamsecure)
|
|
4
|
+
[](https://github.com/Ravindrabisht12/iamsecure/blob/main/LICENSE)
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
**Find vulnerable dependencies and insecure code, ranked by how dangerous they are in *your* app, not just by CVSS.**
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx iamsecure # scan the current folder
|
|
12
|
+
npx iamsecure --ui # open the dashboard
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="docs/images/terminal.png" alt="iamsecure terminal report" width="680">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
Every finding is ranked in two ways:
|
|
4
20
|
|
|
5
21
|
- **Real risk (worst first)** is the default. It shows how dangerous each issue is for *this* codebase.
|
|
6
22
|
- **Severity (high → low)** is the raw CVSS / rule severity, the same thing `npm audit` shows.
|
|
7
23
|
|
|
8
|
-
The two rankings differ a lot in practice.
|
|
24
|
+
The two rankings differ a lot in practice. A 9.8 CVE in a dev-only CLI tool drops to *low*. An 8.1 lodash advisory rises to *critical* because your API route calls the exact `_.template` function the advisory names.
|
|
9
25
|
|
|
10
26
|
It has **zero runtime dependencies** and needs only Node ≥ 18. A security scanner shouldn't add 300 packages to your supply chain.
|
|
11
27
|
|
|
28
|
+
### Example: OWASP Juice Shop
|
|
29
|
+
|
|
30
|
+
A scan of [OWASP Juice Shop](https://github.com/juice-shop/juice-shop), a deliberately vulnerable Node.js app:
|
|
31
|
+
|
|
32
|
+
| | critical | high | medium | low |
|
|
33
|
+
|---|---|---|---|---|
|
|
34
|
+
| By raw severity | 16 | 51 | 44 | 2 |
|
|
35
|
+
| **By real risk** | **16** | **40** | **44** | **13** |
|
|
36
|
+
|
|
37
|
+
It covered 1,181 packages and 377 source files in about 5 seconds, and found the app's planted SQL injection in `routes/login.ts`, its `eval`, its path traversal routes and 11 hard-coded secrets.
|
|
38
|
+
|
|
39
|
+
Twelve "high" findings dropped to medium or low:
|
|
40
|
+
- Nine are in `tar`, which is installed but never imported anywhere.
|
|
41
|
+
- The rest are in a dev-only build tool and a test file.
|
|
42
|
+
|
|
43
|
+
One "high" `jsonwebtoken` advisory rose to *critical*, because the server's auth code imports that package.
|
|
44
|
+
|
|
45
|
+
## Dashboard
|
|
46
|
+
|
|
47
|
+
`npx iamsecure --ui` opens a local dashboard. Its **Fix list** groups every issue into one action, explains it in plain language (what's wrong, what could happen, where it runs, how to fix) and gives the exact install command.
|
|
48
|
+
|
|
49
|
+
<p align="center">
|
|
50
|
+
<img src="docs/images/dashboard.png" alt="iamsecure dashboard: fix list" width="820">
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
The **Dependency tree** shows which of your direct dependencies brings in each vulnerable package. Its search answers "why is this installed?".
|
|
54
|
+
|
|
55
|
+
<p align="center">
|
|
56
|
+
<img src="docs/images/dependency-tree.png" alt="iamsecure dependency tree" width="820">
|
|
57
|
+
</p>
|
|
58
|
+
|
|
12
59
|
## Quick start
|
|
13
60
|
|
|
14
61
|
```bash
|
package/bin/iamsecure.js
CHANGED
|
@@ -121,13 +121,18 @@ try {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
if (values.ui) {
|
|
124
|
-
const { url } = await serveDashboard(report, {
|
|
124
|
+
const { url, replaced } = await serveDashboard(report, {
|
|
125
125
|
port: Number(values.port),
|
|
126
126
|
open: !values['no-open'],
|
|
127
127
|
rescan: runScan,
|
|
128
128
|
aiProvider: values.ai ?? null,
|
|
129
129
|
analyze: values.ai ? makeAnalyzer({ provider: values.ai, model: values['ai-model'] }) : undefined,
|
|
130
130
|
});
|
|
131
|
+
if (replaced) console.log(c.dim(`\nStopped the previous dashboard (${replaced.root}) and took over port ${replaced.port}.`));
|
|
132
|
+
const port = Number(new URL(url).port);
|
|
133
|
+
if (port !== Number(values.port)) {
|
|
134
|
+
console.log(c.yellow(`\n⚠ Port ${values.port} is busy by another app, using ${port}.`));
|
|
135
|
+
}
|
|
131
136
|
console.log(`\n${c.bold('Dashboard:')} ${c.cyan(url)} ${c.dim('(Ctrl+C to stop)')}`);
|
|
132
137
|
}
|
|
133
138
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iamsecure",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Scan a codebase for vulnerable dependencies and insecure code, ranked by real-world risk \u2014 with an optional AI second opinion.",
|
|
5
5
|
"author": "Ravindra Bisht",
|
|
6
6
|
"repository": {
|
package/src/code/engine.js
CHANGED
|
@@ -43,9 +43,20 @@ export function taintedNames(code, lang) {
|
|
|
43
43
|
return names;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The sink and its arguments: up to the `)` that closes the call (works without semicolons),
|
|
48
|
+
* or to the end of the line for sinks that aren't calls (`el.innerHTML = …`, `echo …`).
|
|
49
|
+
*/
|
|
46
50
|
function argsAfter(window, idx) {
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
const open = window.indexOf('(', idx);
|
|
52
|
+
const lineEnd = window.indexOf('\n', idx);
|
|
53
|
+
if (open === -1 || (lineEnd !== -1 && open > lineEnd)) return window.slice(idx, lineEnd === -1 ? undefined : lineEnd);
|
|
54
|
+
let depth = 0;
|
|
55
|
+
for (let i = open; i < window.length; i++) {
|
|
56
|
+
if (window[i] === '(') depth++;
|
|
57
|
+
else if (window[i] === ')' && --depth === 0) return window.slice(idx, i + 1);
|
|
58
|
+
}
|
|
59
|
+
return window.slice(idx);
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
const DYNAMIC = /\$\{|['"`]\s*\+|\+\s*['"`\w$]|\(\s*[\w$.[\]]+\s*[,)]|\(\s*\w+\s*\(|=\s*[\w$.[\]()]+\s*;?\s*$|\$\w+|\bf['"]|%\s*\(|\.format\(/;
|
package/src/report/terminal.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
1
2
|
import { sortFindings } from '../scan.js';
|
|
2
3
|
|
|
3
4
|
const useColor = (process.stdout.isTTY || !!process.env.FORCE_COLOR) && !process.env.NO_COLOR;
|
|
@@ -209,7 +210,9 @@ export function printReport(report, { sort = 'risk', limit = 30, min } = {}) {
|
|
|
209
210
|
out.push(c.gray('━'.repeat(width)));
|
|
210
211
|
const fw = report.project.framework;
|
|
211
212
|
const brand = ` ${c.bold('iamsecure')} ${c.gray('v' + report.tool.version)}`;
|
|
212
|
-
const
|
|
213
|
+
const home = os.homedir();
|
|
214
|
+
const shown = report.project.root.startsWith(home) ? '~' + report.project.root.slice(home.length) : report.project.root;
|
|
215
|
+
const where = c.gray(ellipsisStart(shown, Math.max(10, width - 30)));
|
|
213
216
|
out.push(brand + ' '.repeat(Math.max(2, width - vlen(brand) - vlen(where) - 2)) + where);
|
|
214
217
|
out.push(c.gray('━'.repeat(width)));
|
|
215
218
|
out.push('');
|
package/src/scan.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { loadProject } from
|
|
2
|
-
import { loadGraphs, scanDependencies, treeStats } from
|
|
3
|
-
import { LEVELS } from
|
|
4
|
-
import { analyzeSource } from
|
|
5
|
-
import { scanCode } from
|
|
6
|
-
import { scanSecrets } from
|
|
7
|
-
import { loadExploitData } from
|
|
8
|
-
import { scoreFindings } from
|
|
1
|
+
import { loadProject } from "./project.js";
|
|
2
|
+
import { loadGraphs, scanDependencies, treeStats } from "./deps/index.js";
|
|
3
|
+
import { LEVELS } from "./deps/cvss.js";
|
|
4
|
+
import { analyzeSource } from "./code/index.js";
|
|
5
|
+
import { scanCode } from "./code/engine.js";
|
|
6
|
+
import { scanSecrets } from "./code/secrets.js";
|
|
7
|
+
import { loadExploitData } from "./risk/exploit.js";
|
|
8
|
+
import { scoreFindings } from "./risk/score.js";
|
|
9
9
|
|
|
10
|
-
export const VERSION =
|
|
10
|
+
export const VERSION = "0.1.3";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Run a full scan and return a JSON-serialisable report.
|
|
@@ -37,14 +37,14 @@ export async function scan(root, opts = {}) {
|
|
|
37
37
|
|
|
38
38
|
let src = null;
|
|
39
39
|
if (code) {
|
|
40
|
-
log(
|
|
40
|
+
log("Analysing source code…");
|
|
41
41
|
src = analyzeSource(project);
|
|
42
42
|
stats.files = src.contexts.size;
|
|
43
43
|
findings.push(...scanCode(src), ...scanSecrets(src, project.root));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
let exploitData;
|
|
47
|
-
const cves = [...new Set(findings.flatMap((f) => (f.advisory ? [f.advisory.id, ...f.advisory.aliases] : [])).filter((id) => id.startsWith(
|
|
47
|
+
const cves = [...new Set(findings.flatMap((f) => (f.advisory ? [f.advisory.id, ...f.advisory.aliases] : [])).filter((id) => id.startsWith("CVE-")))];
|
|
48
48
|
if (exploit && cves.length) {
|
|
49
49
|
log(`Checking ${cves.length} CVEs against CISA KEV + EPSS…`);
|
|
50
50
|
exploitData = await loadExploitData(cves);
|
|
@@ -54,7 +54,7 @@ export async function scan(root, opts = {}) {
|
|
|
54
54
|
scoreFindings(findings, { src, framework: project.framework, exploit: exploitData });
|
|
55
55
|
|
|
56
56
|
return {
|
|
57
|
-
tool: { name:
|
|
57
|
+
tool: { name: "iamsecure", version: VERSION },
|
|
58
58
|
scannedAt: new Date().toISOString(),
|
|
59
59
|
durationMs: Date.now() - started,
|
|
60
60
|
project: { name: project.name, root: project.root, framework: project.framework },
|
|
@@ -62,20 +62,20 @@ export async function scan(root, opts = {}) {
|
|
|
62
62
|
stats,
|
|
63
63
|
warnings,
|
|
64
64
|
summary: summarize(findings),
|
|
65
|
-
findings: sortFindings(findings,
|
|
65
|
+
findings: sortFindings(findings, "risk"),
|
|
66
66
|
tree: graphs.map(serializeGraph),
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export function sortFindings(findings, by =
|
|
71
|
-
const primary = by ===
|
|
72
|
-
const secondary = by ===
|
|
70
|
+
export function sortFindings(findings, by = "risk") {
|
|
71
|
+
const primary = by === "severity" ? "severityScore" : "riskScore";
|
|
72
|
+
const secondary = by === "severity" ? "riskScore" : "severityScore";
|
|
73
73
|
return [...findings].sort((a, b) => b[primary] - a[primary] || b[secondary] - a[secondary] || a.title.localeCompare(b.title));
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export function summarize(findings) {
|
|
77
77
|
const count = (field) => Object.fromEntries(LEVELS.slice(0, 4).map((l) => [l, findings.filter((f) => f[field] === l).length]));
|
|
78
|
-
return { total: findings.length, bySeverity: count(
|
|
78
|
+
return { total: findings.length, bySeverity: count("severity"), byRisk: count("risk") };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/** Compact tree for the dashboard: direct deps and the vulnerable packages beneath them. */
|
|
@@ -84,8 +84,6 @@ function serializeGraph(g) {
|
|
|
84
84
|
ecosystem: g.ecosystem,
|
|
85
85
|
lockfile: g.lockfile,
|
|
86
86
|
roots: g.roots.map((r) => ({ name: r.name, key: r.key, dev: r.dev, workspace: r.workspace })),
|
|
87
|
-
nodes: Object.fromEntries(
|
|
88
|
-
[...g.nodes.values()].filter((n) => !n.local).map((n) => [n.key, { deps: [...n.deps], prod: !!n.prod }]),
|
|
89
|
-
),
|
|
87
|
+
nodes: Object.fromEntries([...g.nodes.values()].filter((n) => !n.local).map((n) => [n.key, { deps: [...n.deps], prod: !!n.prod }])),
|
|
90
88
|
};
|
|
91
89
|
}
|
package/src/ui/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>iamsecure report</title>
|
|
7
|
-
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23142b3b' d='M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z'/%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2.2' d='m8.5 12 2.5 2.5 4.5-5'/%3E%3C/svg%3E">
|
|
8
8
|
<style>
|
|
9
9
|
:root {
|
|
10
10
|
--bg: #ffffff; --strip: #0f2b3d; --text: #142b3b; --muted: #5b6b78; --faint: #8795a1;
|
|
@@ -409,6 +409,7 @@ function groupRow(name, list) {
|
|
|
409
409
|
function header(r) {
|
|
410
410
|
const s = r.summary;
|
|
411
411
|
const p = r.stats.packages;
|
|
412
|
+
document.title = `${r.project.name} — iamsecure`;
|
|
412
413
|
$('#crumb').innerHTML = `<span>Projects</span><span class="sep">›</span><span>${esc(r.project.name)}</span>`;
|
|
413
414
|
$('#counters').innerHTML = LEVELS.map((l) => `
|
|
414
415
|
<button class="counter ${l} ${s.byRisk[l] ? 'has' : ''} ${state.level === l ? 'on' : ''}" data-level="${l}" title="Show only ${l} (real risk)">
|
|
@@ -821,7 +822,7 @@ function nodeHtml(g, key, path, { root = null } = {}) {
|
|
|
821
822
|
const belowOnly = under.size - (v ? 1 : 0);
|
|
822
823
|
const kids = kidsOf(g, key, path);
|
|
823
824
|
const node = g.nodes[key];
|
|
824
|
-
const worst =
|
|
825
|
+
const worst = worstOf(under); // the card edge shows the worst issue anywhere in this branch
|
|
825
826
|
const bits = [
|
|
826
827
|
kids.length ? `<button class="tv-tog" type="button" aria-expanded="false" aria-label="Show what ${esc(name)} depends on">›</button>` : '<span class="tv-leaf"></span>',
|
|
827
828
|
dot(v ? v.worst : under.size ? 'muted' : 'ok'),
|
package/src/ui/server.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
2
3
|
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
import crypto from 'node:crypto';
|
|
4
6
|
import { spawn } from 'node:child_process';
|
|
5
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { cacheDir } from '../util/cache.js';
|
|
6
9
|
|
|
7
10
|
const PAGE = fileURLToPath(new URL('./index.html', import.meta.url));
|
|
8
11
|
|
|
@@ -14,16 +17,64 @@ function openBrowser(url) {
|
|
|
14
17
|
} catch {}
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
|
|
22
|
+
// Each running dashboard records {pid, port, secret, root} in a user-only file, so a newer run can
|
|
23
|
+
// ask it to stop. Only someone who can read that file knows the secret, so websites can't do it.
|
|
24
|
+
const lockFile = (port) => path.join(cacheDir, `dashboard-${port}.json`);
|
|
25
|
+
|
|
26
|
+
function portFree(port) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const probe = net.createServer();
|
|
29
|
+
probe.once('error', () => resolve(false));
|
|
30
|
+
probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ask the dashboard holding `port` to shut down. Returns its lock info if it stopped, else null. */
|
|
35
|
+
async function stopPrevious(port) {
|
|
36
|
+
let info;
|
|
37
|
+
try {
|
|
38
|
+
info = JSON.parse(fs.readFileSync(lockFile(port), 'utf8'));
|
|
39
|
+
} catch {
|
|
40
|
+
return null; // not one of ours (or from a version without this feature)
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/shutdown`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'x-iamsecure-shutdown': info.secret },
|
|
46
|
+
signal: AbortSignal.timeout(2000),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) return null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i < 40; i++) {
|
|
53
|
+
if (await portFree(port)) return info;
|
|
54
|
+
await sleep(100);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
function listen(server, port, attempts = 20) {
|
|
60
|
+
// One 'listening' handler that reads the port actually bound. (A callback per attempt would
|
|
61
|
+
// also fire for the failed attempts and report the busy port — opening another dashboard.)
|
|
18
62
|
return new Promise((resolve, reject) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
63
|
+
let p = port;
|
|
64
|
+
const onError = (err) => {
|
|
65
|
+
if (err.code === 'EADDRINUSE' && attempts-- > 0) server.listen(++p, '127.0.0.1');
|
|
66
|
+
else {
|
|
67
|
+
server.off('listening', onListening);
|
|
68
|
+
reject(err);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const onListening = () => {
|
|
72
|
+
server.off('error', onError);
|
|
73
|
+
resolve(server.address().port);
|
|
25
74
|
};
|
|
26
|
-
|
|
75
|
+
server.on('error', onError);
|
|
76
|
+
server.once('listening', onListening);
|
|
77
|
+
server.listen(p, '127.0.0.1');
|
|
27
78
|
});
|
|
28
79
|
}
|
|
29
80
|
|
|
@@ -36,7 +87,14 @@ function listen(server, port, attempts = 20) {
|
|
|
36
87
|
export async function serveDashboard(initialReport, { port = 4477, open = true, rescan, analyze, aiProvider = null } = {}) {
|
|
37
88
|
let report = initialReport;
|
|
38
89
|
const token = crypto.randomBytes(16).toString('hex');
|
|
90
|
+
const shutdownSecret = crypto.randomBytes(24).toString('hex');
|
|
39
91
|
let busy = false;
|
|
92
|
+
let lockPath = null;
|
|
93
|
+
const removeLock = () => {
|
|
94
|
+
try {
|
|
95
|
+
if (lockPath && JSON.parse(fs.readFileSync(lockPath, 'utf8')).pid === process.pid) fs.unlinkSync(lockPath);
|
|
96
|
+
} catch {}
|
|
97
|
+
};
|
|
40
98
|
|
|
41
99
|
const server = http.createServer(async (req, res) => {
|
|
42
100
|
const host = (req.headers.host ?? '').split(':')[0];
|
|
@@ -62,6 +120,15 @@ export async function serveDashboard(initialReport, { port = 4477, open = true,
|
|
|
62
120
|
.replace('__IAMSECURE_AI__', JSON.stringify(aiProvider));
|
|
63
121
|
return send(200, html, 'text/html; charset=utf-8');
|
|
64
122
|
}
|
|
123
|
+
if (req.method === 'POST' && url.pathname === '/api/shutdown') {
|
|
124
|
+
if (req.headers['x-iamsecure-shutdown'] !== shutdownSecret) return send(403, { error: 'forbidden' });
|
|
125
|
+
send(200, { ok: true });
|
|
126
|
+
console.log('\nThis dashboard was replaced by a newer `iamsecure --ui` run. Stopping.');
|
|
127
|
+
removeLock();
|
|
128
|
+
server.close();
|
|
129
|
+
setTimeout(() => process.exit(0), 100);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
65
132
|
if (!url.pathname.startsWith('/api/')) return send(404, { error: 'not found' });
|
|
66
133
|
if (req.headers['x-iamsecure-token'] !== token) return send(401, { error: 'bad token' });
|
|
67
134
|
|
|
@@ -90,8 +157,24 @@ export async function serveDashboard(initialReport, { port = 4477, open = true,
|
|
|
90
157
|
}
|
|
91
158
|
});
|
|
92
159
|
|
|
160
|
+
// Replace an older iamsecure dashboard on the same port instead of piling up new ones.
|
|
161
|
+
const replaced = (await portFree(port)) ? null : await stopPrevious(port);
|
|
162
|
+
|
|
93
163
|
const actual = await listen(server, port);
|
|
164
|
+
lockPath = lockFile(actual);
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
167
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, port: actual, secret: shutdownSecret, root: report.project.root, startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
168
|
+
} catch {}
|
|
169
|
+
process.once('exit', removeLock);
|
|
170
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
171
|
+
process.once(sig, () => {
|
|
172
|
+
removeLock();
|
|
173
|
+
process.exit(sig === 'SIGINT' ? 130 : 0);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
94
177
|
const url = `http://localhost:${actual}/`;
|
|
95
178
|
if (open) openBrowser(url);
|
|
96
|
-
return { url, server };
|
|
179
|
+
return { url, server, replaced };
|
|
97
180
|
}
|