iamsecure 0.0.0-stage → 0.1.2
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 +21 -0
- package/README.md +174 -2
- package/bin/iamsecure.js +136 -0
- package/package.json +36 -4
- package/src/ai/index.js +170 -0
- package/src/ai/providers.js +161 -0
- package/src/code/context.js +123 -0
- package/src/code/engine.js +128 -0
- package/src/code/files.js +85 -0
- package/src/code/index.js +30 -0
- package/src/code/rules.js +545 -0
- package/src/code/secrets.js +123 -0
- package/src/code/strip.js +51 -0
- package/src/deps/cvss.js +59 -0
- package/src/deps/graph.js +88 -0
- package/src/deps/index.js +144 -0
- package/src/deps/lockfiles/composer.js +31 -0
- package/src/deps/lockfiles/npm.js +77 -0
- package/src/deps/lockfiles/pnpm.js +56 -0
- package/src/deps/lockfiles/python.js +101 -0
- package/src/deps/lockfiles/yarn.js +93 -0
- package/src/deps/osv.js +106 -0
- package/src/project.js +102 -0
- package/src/report/sarif.js +58 -0
- package/src/report/terminal.js +256 -0
- package/src/risk/exploit.js +53 -0
- package/src/risk/score.js +215 -0
- package/src/scan.js +89 -0
- package/src/ui/index.html +980 -0
- package/src/ui/server.js +97 -0
- package/src/util/cache.js +35 -0
- package/src/util/http.js +43 -0
- package/src/util/semver.js +23 -0
- package/src/util/toml.js +129 -0
- package/src/util/yaml.js +85 -0
package/src/ui/server.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const PAGE = fileURLToPath(new URL('./index.html', import.meta.url));
|
|
8
|
+
|
|
9
|
+
function openBrowser(url) {
|
|
10
|
+
const [cmd, args] =
|
|
11
|
+
process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
12
|
+
try {
|
|
13
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function listen(server, port, attempts = 20) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const tryPort = (p, left) => {
|
|
20
|
+
server.once('error', (err) => {
|
|
21
|
+
if (err.code === 'EADDRINUSE' && left > 0) tryPort(p + 1, left - 1);
|
|
22
|
+
else reject(err);
|
|
23
|
+
});
|
|
24
|
+
server.listen(p, '127.0.0.1', () => resolve(p));
|
|
25
|
+
};
|
|
26
|
+
tryPort(port, attempts);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Serve the dashboard for a report. Bound to 127.0.0.1 only; every API call needs the session
|
|
32
|
+
* token and a localhost Host header so other sites can't read the report (DNS rebinding / CSRF).
|
|
33
|
+
* @param {any} initialReport
|
|
34
|
+
* @param {{port?: number, open?: boolean, rescan?: () => Promise<any>, analyze?: (findingId: string) => Promise<any>, aiProvider?: string|null}} opts
|
|
35
|
+
*/
|
|
36
|
+
export async function serveDashboard(initialReport, { port = 4477, open = true, rescan, analyze, aiProvider = null } = {}) {
|
|
37
|
+
let report = initialReport;
|
|
38
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
39
|
+
let busy = false;
|
|
40
|
+
|
|
41
|
+
const server = http.createServer(async (req, res) => {
|
|
42
|
+
const host = (req.headers.host ?? '').split(':')[0];
|
|
43
|
+
if (!['127.0.0.1', 'localhost'].includes(host)) {
|
|
44
|
+
res.writeHead(403).end('Forbidden');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const send = (status, body, type = 'application/json') => {
|
|
48
|
+
res.writeHead(status, {
|
|
49
|
+
'content-type': type,
|
|
50
|
+
'cache-control': 'no-store',
|
|
51
|
+
'x-content-type-options': 'nosniff',
|
|
52
|
+
'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:",
|
|
53
|
+
});
|
|
54
|
+
res.end(type === 'application/json' ? JSON.stringify(body) : body);
|
|
55
|
+
};
|
|
56
|
+
const url = new URL(req.url, 'http://localhost');
|
|
57
|
+
|
|
58
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
59
|
+
const html = fs
|
|
60
|
+
.readFileSync(PAGE, 'utf8')
|
|
61
|
+
.replace('__IAMSECURE_TOKEN__', token)
|
|
62
|
+
.replace('__IAMSECURE_AI__', JSON.stringify(aiProvider));
|
|
63
|
+
return send(200, html, 'text/html; charset=utf-8');
|
|
64
|
+
}
|
|
65
|
+
if (!url.pathname.startsWith('/api/')) return send(404, { error: 'not found' });
|
|
66
|
+
if (req.headers['x-iamsecure-token'] !== token) return send(401, { error: 'bad token' });
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
if (req.method === 'GET' && url.pathname === '/api/report') return send(200, report);
|
|
70
|
+
if (req.method === 'POST' && url.pathname === '/api/rescan' && rescan) {
|
|
71
|
+
if (busy) return send(409, { error: 'A scan is already running' });
|
|
72
|
+
busy = true;
|
|
73
|
+
try {
|
|
74
|
+
report = await rescan();
|
|
75
|
+
} finally {
|
|
76
|
+
busy = false;
|
|
77
|
+
}
|
|
78
|
+
return send(200, report);
|
|
79
|
+
}
|
|
80
|
+
if (req.method === 'POST' && url.pathname === '/api/analyze' && analyze) {
|
|
81
|
+
const id = url.searchParams.get('id');
|
|
82
|
+
const finding = report.findings.find((f) => f.id === id);
|
|
83
|
+
if (!finding) return send(404, { error: 'unknown finding' });
|
|
84
|
+
const ai = await analyze(finding, report);
|
|
85
|
+
return send(200, finding.ai ? finding : { ...finding, ai });
|
|
86
|
+
}
|
|
87
|
+
send(404, { error: 'not found' });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
send(500, { error: err.message });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const actual = await listen(server, port);
|
|
94
|
+
const url = `http://localhost:${actual}/`;
|
|
95
|
+
if (open) openBrowser(url);
|
|
96
|
+
return { url, server };
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const baseDir =
|
|
7
|
+
process.env.IAMSECURE_CACHE_DIR ||
|
|
8
|
+
path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'iamsecure');
|
|
9
|
+
|
|
10
|
+
const fileFor = (ns, key) =>
|
|
11
|
+
path.join(baseDir, ns, crypto.createHash('sha256').update(key).digest('hex').slice(0, 40) + '.json');
|
|
12
|
+
|
|
13
|
+
/** Returns undefined on miss, expiry or any read error. */
|
|
14
|
+
export function cacheGet(ns, key, maxAgeMs) {
|
|
15
|
+
if (process.env.IAMSECURE_NO_CACHE) return undefined;
|
|
16
|
+
const file = fileFor(ns, key);
|
|
17
|
+
try {
|
|
18
|
+
if (maxAgeMs && Date.now() - fs.statSync(file).mtimeMs > maxAgeMs) return undefined;
|
|
19
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cacheSet(ns, key, value) {
|
|
26
|
+
const file = fileFor(ns, key);
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify(value));
|
|
30
|
+
} catch {
|
|
31
|
+
// A cache that can't be written is just a slower scan.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const cacheDir = baseDir;
|
package/src/util/http.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* fetch() + JSON with timeout and retry on 429/5xx/network errors.
|
|
5
|
+
* @param {string} url
|
|
6
|
+
* @param {{method?: string, body?: any, headers?: Record<string,string>, timeout?: number, retries?: number}} [opts]
|
|
7
|
+
*/
|
|
8
|
+
export async function fetchJson(url, { method = 'GET', body, headers = {}, timeout = 30000, retries = 2 } = {}) {
|
|
9
|
+
for (let attempt = 0; ; attempt++) {
|
|
10
|
+
let retryable = true;
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(url, {
|
|
13
|
+
method,
|
|
14
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
15
|
+
headers: { 'content-type': 'application/json', 'user-agent': 'iamsecure', ...headers },
|
|
16
|
+
signal: AbortSignal.timeout(timeout),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
retryable = res.status === 429 || res.status >= 500;
|
|
20
|
+
const text = await res.text().catch(() => '');
|
|
21
|
+
throw new Error(`${method} ${url} → HTTP ${res.status} ${text.slice(0, 300)}`);
|
|
22
|
+
}
|
|
23
|
+
return await res.json();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (!retryable || attempt >= retries) throw err;
|
|
26
|
+
await sleep(600 * 2 ** attempt);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Run fn over items with at most `limit` in flight. Results keep input order. */
|
|
32
|
+
export async function mapLimit(items, limit, fn) {
|
|
33
|
+
const out = new Array(items.length);
|
|
34
|
+
let next = 0;
|
|
35
|
+
const worker = async () => {
|
|
36
|
+
while (next < items.length) {
|
|
37
|
+
const i = next++;
|
|
38
|
+
out[i] = await fn(items[i], i);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Loose version comparison that works well enough for npm, Packagist and PyPI versions.
|
|
2
|
+
|
|
3
|
+
function parse(v) {
|
|
4
|
+
const s = String(v).trim().replace(/^v/i, '').split('+')[0];
|
|
5
|
+
const m = s.match(/^(\d+(?:\.\d+)*)(.*)$/);
|
|
6
|
+
if (!m) return { nums: [], pre: s };
|
|
7
|
+
return { nums: m[1].split('.').map(Number), pre: m[2].replace(/^[-.]/, '') };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @returns {-1|0|1} */
|
|
11
|
+
export function compareVersions(a, b) {
|
|
12
|
+
const pa = parse(a);
|
|
13
|
+
const pb = parse(b);
|
|
14
|
+
const len = Math.max(pa.nums.length, pb.nums.length);
|
|
15
|
+
for (let i = 0; i < len; i++) {
|
|
16
|
+
const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
|
|
17
|
+
if (d) return d > 0 ? 1 : -1;
|
|
18
|
+
}
|
|
19
|
+
if (pa.pre === pb.pre) return 0;
|
|
20
|
+
if (!pa.pre) return 1;
|
|
21
|
+
if (!pb.pre) return -1;
|
|
22
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
23
|
+
}
|
package/src/util/toml.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Minimal TOML subset parser for lockfiles and pyproject.toml: [tables], [[array tables]],
|
|
2
|
+
// dotted keys, strings, numbers, booleans, (multi-line) arrays and inline tables.
|
|
3
|
+
|
|
4
|
+
function parseValue(src, i) {
|
|
5
|
+
const skipWs = () => {
|
|
6
|
+
while (i < src.length) {
|
|
7
|
+
if (/\s/.test(src[i])) i++;
|
|
8
|
+
else if (src[i] === '#') while (i < src.length && src[i] !== '\n') i++;
|
|
9
|
+
else break;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
skipWs();
|
|
13
|
+
const ch = src[i];
|
|
14
|
+
if (src.startsWith('"""', i) || src.startsWith("'''", i)) {
|
|
15
|
+
const q = src.slice(i, i + 3);
|
|
16
|
+
const end = src.indexOf(q, i + 3);
|
|
17
|
+
return [src.slice(i + 3, end).replace(/^\n/, ''), end + 3];
|
|
18
|
+
}
|
|
19
|
+
if (ch === '"') {
|
|
20
|
+
let j = i + 1;
|
|
21
|
+
let out = '';
|
|
22
|
+
while (j < src.length && src[j] !== '"') {
|
|
23
|
+
if (src[j] === '\\') {
|
|
24
|
+
const n = src[j + 1];
|
|
25
|
+
out += { n: '\n', t: '\t', '"': '"', '\\': '\\' }[n] ?? n;
|
|
26
|
+
j += 2;
|
|
27
|
+
} else out += src[j++];
|
|
28
|
+
}
|
|
29
|
+
return [out, j + 1];
|
|
30
|
+
}
|
|
31
|
+
if (ch === "'") {
|
|
32
|
+
const end = src.indexOf("'", i + 1);
|
|
33
|
+
return [src.slice(i + 1, end), end + 1];
|
|
34
|
+
}
|
|
35
|
+
if (ch === '[') {
|
|
36
|
+
const arr = [];
|
|
37
|
+
i++;
|
|
38
|
+
for (;;) {
|
|
39
|
+
skipWs();
|
|
40
|
+
if (src[i] === ']') return [arr, i + 1];
|
|
41
|
+
const [v, next] = parseValue(src, i);
|
|
42
|
+
arr.push(v);
|
|
43
|
+
i = next;
|
|
44
|
+
skipWs();
|
|
45
|
+
if (src[i] === ',') i++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (ch === '{') {
|
|
49
|
+
const obj = {};
|
|
50
|
+
i++;
|
|
51
|
+
for (;;) {
|
|
52
|
+
skipWs();
|
|
53
|
+
if (src[i] === '}') return [obj, i + 1];
|
|
54
|
+
const m = /^\s*("[^"]*"|'[^']*'|[\w.-]+)\s*=/.exec(src.slice(i, i + 200));
|
|
55
|
+
if (!m) return [obj, src.indexOf('}', i) + 1];
|
|
56
|
+
i += m[0].length;
|
|
57
|
+
const [v, next] = parseValue(src, i);
|
|
58
|
+
setPath(obj, splitKey(m[1]), v);
|
|
59
|
+
i = next;
|
|
60
|
+
skipWs();
|
|
61
|
+
if (src[i] === ',') i++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const m = /^[^,\]}\n#]+/.exec(src.slice(i));
|
|
65
|
+
const raw = (m?.[0] ?? '').trim();
|
|
66
|
+
const v = raw === 'true' ? true : raw === 'false' ? false : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
|
|
67
|
+
return [v, i + (m?.[0].length ?? 0)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function splitKey(k) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
for (const m of k.matchAll(/"([^"]*)"|'([^']*)'|([^.\s]+)/g)) parts.push(m[1] ?? m[2] ?? m[3]);
|
|
73
|
+
return parts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setPath(obj, keys, value) {
|
|
77
|
+
let o = obj;
|
|
78
|
+
for (const k of keys.slice(0, -1)) {
|
|
79
|
+
if (Array.isArray(o[k])) o = o[k].at(-1);
|
|
80
|
+
else o = o[k] ??= {};
|
|
81
|
+
}
|
|
82
|
+
o[keys.at(-1)] = value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseToml(src) {
|
|
86
|
+
const root = {};
|
|
87
|
+
let current = root;
|
|
88
|
+
let i = 0;
|
|
89
|
+
while (i < src.length) {
|
|
90
|
+
const rest = src.slice(i);
|
|
91
|
+
const ws = /^\s+/.exec(rest);
|
|
92
|
+
if (ws) {
|
|
93
|
+
i += ws[0].length;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (rest[0] === '#') {
|
|
97
|
+
i = src.indexOf('\n', i) === -1 ? src.length : src.indexOf('\n', i);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const arrTable = /^\[\[\s*([^\]]+?)\s*\]\]/.exec(rest);
|
|
101
|
+
if (arrTable) {
|
|
102
|
+
const keys = splitKey(arrTable[1]);
|
|
103
|
+
let o = root;
|
|
104
|
+
for (const k of keys.slice(0, -1)) o = Array.isArray(o[k]) ? o[k].at(-1) : (o[k] ??= {});
|
|
105
|
+
const last = keys.at(-1);
|
|
106
|
+
if (!Array.isArray(o[last])) o[last] = [];
|
|
107
|
+
current = {};
|
|
108
|
+
o[last].push(current);
|
|
109
|
+
i += arrTable[0].length;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const table = /^\[\s*([^\]]+?)\s*\]/.exec(rest);
|
|
113
|
+
if (table) {
|
|
114
|
+
current = root;
|
|
115
|
+
for (const k of splitKey(table[1])) current = Array.isArray(current[k]) ? current[k].at(-1) : (current[k] ??= {});
|
|
116
|
+
i += table[0].length;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const kv = /^("[^"]*"|'[^']*'|[\w.-]+)\s*=\s*/.exec(rest);
|
|
120
|
+
if (!kv) {
|
|
121
|
+
i = src.indexOf('\n', i) === -1 ? src.length : src.indexOf('\n', i) + 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const [value, next] = parseValue(src, i + kv[0].length);
|
|
125
|
+
setPath(current, splitKey(kv[1]), value);
|
|
126
|
+
i = next;
|
|
127
|
+
}
|
|
128
|
+
return root;
|
|
129
|
+
}
|
package/src/util/yaml.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Minimal YAML subset parser — enough for pnpm-lock.yaml and yarn berry lockfiles:
|
|
2
|
+
// block mappings, block sequences of scalars, quoted/plain scalars. Flow collections
|
|
3
|
+
// ({...} / [...]) are kept as raw strings since lockfile parsing never needs them.
|
|
4
|
+
|
|
5
|
+
function unquote(s) {
|
|
6
|
+
s = s.trim();
|
|
7
|
+
if (s.length >= 2 && s[0] === '"' && s.at(-1) === '"') {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(s);
|
|
10
|
+
} catch {
|
|
11
|
+
return s.slice(1, -1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (s.length >= 2 && s[0] === "'" && s.at(-1) === "'") return s.slice(1, -1).replace(/''/g, "'");
|
|
15
|
+
return s;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Split "key: value" honoring a quoted key. Returns null when the line isn't a mapping entry. */
|
|
19
|
+
function splitKey(line) {
|
|
20
|
+
if (line[0] === '"' || line[0] === "'") {
|
|
21
|
+
const q = line[0];
|
|
22
|
+
let i = 1;
|
|
23
|
+
while (i < line.length) {
|
|
24
|
+
if (line[i] === '\\' && q === '"') i += 2;
|
|
25
|
+
else if (line[i] === q && q === "'" && line[i + 1] === q) i += 2;
|
|
26
|
+
else if (line[i] === q) break;
|
|
27
|
+
else i++;
|
|
28
|
+
}
|
|
29
|
+
const rest = line.slice(i + 1);
|
|
30
|
+
if (!rest.startsWith(':')) return null;
|
|
31
|
+
return [unquote(line.slice(0, i + 1)), rest.slice(1).trim()];
|
|
32
|
+
}
|
|
33
|
+
const m = line.match(/^(.*?):(?:\s+(.*))?$/);
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
return [m[1].trim(), (m[2] ?? '').trim()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stripComment(v) {
|
|
39
|
+
if (!v || v[0] === '"' || v[0] === "'") return v;
|
|
40
|
+
const i = v.indexOf(' #');
|
|
41
|
+
return i === -1 ? v : v.slice(0, i).trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseYaml(text) {
|
|
45
|
+
const root = {};
|
|
46
|
+
// Each frame: indent of the line that opened it, the container, and where it lives.
|
|
47
|
+
const stack = [{ indent: -1, obj: root, owner: null, key: null }];
|
|
48
|
+
|
|
49
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
50
|
+
const trimmed = raw.trim();
|
|
51
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed === '---') continue;
|
|
52
|
+
const indent = raw.length - raw.trimStart().length;
|
|
53
|
+
const isItem = trimmed === '-' || trimmed.startsWith('- ');
|
|
54
|
+
|
|
55
|
+
// A sequence may sit at the same indent as its key ("key:\n- a").
|
|
56
|
+
const top = stack.at(-1);
|
|
57
|
+
const sameIndentList = isItem && top.indent === indent && top.owner && isEmptyContainer(top.obj);
|
|
58
|
+
if (!sameIndentList) while (stack.length > 1 && indent <= stack.at(-1).indent) stack.pop();
|
|
59
|
+
|
|
60
|
+
const frame = stack.at(-1);
|
|
61
|
+
if (isItem) {
|
|
62
|
+
if (!Array.isArray(frame.obj)) {
|
|
63
|
+
if (!frame.owner || !isEmptyContainer(frame.obj)) continue;
|
|
64
|
+
frame.obj = frame.owner[frame.key] = [];
|
|
65
|
+
}
|
|
66
|
+
frame.obj.push(unquote(stripComment(trimmed.slice(1).trim())));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const kv = splitKey(trimmed);
|
|
71
|
+
if (!kv || Array.isArray(frame.obj)) continue;
|
|
72
|
+
const [key, value] = kv;
|
|
73
|
+
if (value === '') {
|
|
74
|
+
frame.obj[key] = {};
|
|
75
|
+
stack.push({ indent, obj: frame.obj[key], owner: frame.obj, key });
|
|
76
|
+
} else {
|
|
77
|
+
frame.obj[key] = unquote(stripComment(value));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return root;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isEmptyContainer(o) {
|
|
84
|
+
return (Array.isArray(o) && o.length === 0) || (o && typeof o === 'object' && Object.keys(o).length === 0);
|
|
85
|
+
}
|