@exodus/stasis 1.0.0-alpha.2 → 1.0.0-alpha.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/package.json +2 -1
- package/src/audit.js +149 -0
- package/src/cmd/bundle.js +1 -1
- package/src/loaders/solidity.js +45 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.3",
|
|
4
4
|
"description": "File-level source bundles and lockfiles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"logo.svg",
|
|
18
18
|
"src/apis/npm/index.js",
|
|
19
19
|
"src/apis/npm/semver.cjs",
|
|
20
|
+
"src/audit.js",
|
|
20
21
|
"src/bundle.js",
|
|
21
22
|
"src/cmd/bundle.js",
|
|
22
23
|
"src/config.js",
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { brotliDecompressSync } from 'node:zlib'
|
|
3
|
+
|
|
4
|
+
import { advisories } from './apis/npm/index.js'
|
|
5
|
+
import semver from './apis/npm/semver.cjs'
|
|
6
|
+
import { Bundle } from './bundle.js'
|
|
7
|
+
import { Lockfile } from './lockfile.js'
|
|
8
|
+
|
|
9
|
+
function parseFile(file) {
|
|
10
|
+
let buf
|
|
11
|
+
try {
|
|
12
|
+
buf = readFileSync(file)
|
|
13
|
+
} catch (cause) {
|
|
14
|
+
if (cause.code === 'ENOENT') throw new Error(`File not found: ${file}`, { cause })
|
|
15
|
+
throw new Error(`Failed to read ${file}: ${cause.message}`, { cause })
|
|
16
|
+
}
|
|
17
|
+
// Lockfiles are JSON text starting with `{`; code/resource bundles are
|
|
18
|
+
// brotli-compressed JSON. Sniff to route to the right parser so its
|
|
19
|
+
// diagnostic surfaces on failure.
|
|
20
|
+
if (buf[0] === 0x7b /* '{' */) {
|
|
21
|
+
try {
|
|
22
|
+
return Lockfile.parse(buf.toString('utf8'))
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
throw new Error(`Failed to parse stasis lockfile: ${file}`, { cause })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
let text
|
|
28
|
+
try {
|
|
29
|
+
text = brotliDecompressSync(buf).toString('utf8')
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
throw new Error(`Failed to read ${file} as a stasis bundle (not brotli)`, { cause })
|
|
32
|
+
}
|
|
33
|
+
// Code bundles carry `formats`/`imports`; resource bundles don't. Route on shape.
|
|
34
|
+
try {
|
|
35
|
+
const isCode = JSON.parse(text).formats !== undefined
|
|
36
|
+
return isCode ? Bundle.parseCode(text) : Bundle.parseResources(text)
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
throw new Error(`Failed to parse stasis bundle: ${file}`, { cause })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only audit installed dependencies; workspace/first-party packages live under
|
|
43
|
+
// non-`node_modules` keys in .modules (Lockfile/Bundle merge sources in) and
|
|
44
|
+
// shouldn't be sent to the public registry — they leak names and produce noise.
|
|
45
|
+
export function collectPackagesFromFile(file) {
|
|
46
|
+
const out = []
|
|
47
|
+
for (const [dir, { name, version }] of parseFile(file).modules) {
|
|
48
|
+
if (!dir.includes('node_modules')) continue
|
|
49
|
+
if (name && version) out.push({ name, version })
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function collectPackages(files) {
|
|
55
|
+
const seen = new Set()
|
|
56
|
+
const out = []
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
for (const { name, version } of collectPackagesFromFile(file)) {
|
|
59
|
+
const key = `${name}@${version}`
|
|
60
|
+
if (seen.has(key)) continue
|
|
61
|
+
seen.add(key)
|
|
62
|
+
out.push({ name, version })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 }
|
|
69
|
+
|
|
70
|
+
export function flattenAdvisories(result, packages = []) {
|
|
71
|
+
const installed = new Map()
|
|
72
|
+
for (const { name, version } of packages) {
|
|
73
|
+
if (!installed.has(name)) installed.set(name, [])
|
|
74
|
+
installed.get(name).push(version)
|
|
75
|
+
}
|
|
76
|
+
const rows = []
|
|
77
|
+
for (const [pkg, list] of Object.entries(result)) {
|
|
78
|
+
if (!Array.isArray(list)) continue
|
|
79
|
+
const installedVersions = installed.get(pkg) ?? []
|
|
80
|
+
for (const adv of list) {
|
|
81
|
+
const range = adv.vulnerable_versions ?? ''
|
|
82
|
+
const affected = range
|
|
83
|
+
? installedVersions.filter((v) => semver.satisfies(v, range))
|
|
84
|
+
: installedVersions
|
|
85
|
+
// npm occasionally returns advisories whose vulnerable range matches none
|
|
86
|
+
// of the versions we submitted; drop them so the table (and the CLI exit
|
|
87
|
+
// code) reflect only real hits.
|
|
88
|
+
if (installedVersions.length > 0 && affected.length === 0) continue
|
|
89
|
+
rows.push({
|
|
90
|
+
package: pkg,
|
|
91
|
+
installed: affected.join(', '),
|
|
92
|
+
vulnerable: range,
|
|
93
|
+
severity: adv.severity ?? '',
|
|
94
|
+
title: adv.title ?? '',
|
|
95
|
+
url: adv.url ?? '',
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
rows.sort((a, b) => {
|
|
100
|
+
const sa = SEVERITY_ORDER[a.severity] ?? 99
|
|
101
|
+
const sb = SEVERITY_ORDER[b.severity] ?? 99
|
|
102
|
+
if (sa !== sb) return sa - sb
|
|
103
|
+
if (a.package !== b.package) return a.package < b.package ? -1 : 1
|
|
104
|
+
return a.title < b.title ? -1 : 1
|
|
105
|
+
})
|
|
106
|
+
return rows
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Line breaks in npm advisory titles would otherwise break the box layout.
|
|
110
|
+
// Covers LF, bare CR (and CRLF), and U+2028 / U+2029 line/paragraph separators.
|
|
111
|
+
const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ')
|
|
112
|
+
|
|
113
|
+
export function formatTable(rows, columns) {
|
|
114
|
+
if (rows.length === 0) return ''
|
|
115
|
+
const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)))
|
|
116
|
+
const pad = (s, w) => s.padEnd(w)
|
|
117
|
+
const line = (l, m, r, fill) => l + widths.map((w) => fill.repeat(w + 2)).join(m) + r
|
|
118
|
+
const row = (vals) => '│ ' + vals.map((v, i) => pad(cell(v), widths[i])).join(' │ ') + ' │'
|
|
119
|
+
return [
|
|
120
|
+
line('┌', '┬', '┐', '─'),
|
|
121
|
+
row(columns),
|
|
122
|
+
line('├', '┼', '┤', '─'),
|
|
123
|
+
...rows.map((r) => row(columns.map((c) => r[c]))),
|
|
124
|
+
line('└', '┴', '┘', '─'),
|
|
125
|
+
].join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function audit(files) {
|
|
129
|
+
const packages = collectPackages(files)
|
|
130
|
+
if (packages.length === 0) {
|
|
131
|
+
return { packages, advisories: {}, rows: [] }
|
|
132
|
+
}
|
|
133
|
+
const result = await advisories(packages)
|
|
134
|
+
const rows = flattenAdvisories(result, packages)
|
|
135
|
+
return { packages, advisories: result, rows }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) {
|
|
139
|
+
err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
|
|
140
|
+
if (packages.length === 0) {
|
|
141
|
+
err.write('No node_modules entries found in the input files\n')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (rows.length === 0) {
|
|
145
|
+
err.write('No advisories found\n')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
out.write(formatTable(rows, ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']) + '\n')
|
|
149
|
+
}
|
package/src/cmd/bundle.js
CHANGED
|
@@ -92,7 +92,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
|
|
|
92
92
|
const normalized = normalizeEntries(entries, cwd)
|
|
93
93
|
|
|
94
94
|
const sources = await collectSolidityFilesFromDisk(baseDir, normalized, remappings)
|
|
95
|
-
const { resolutions, missing } = buildSolidityTree(sources, { remappings })
|
|
95
|
+
const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir })
|
|
96
96
|
|
|
97
97
|
// Bundles must be self-contained. Refuse to write one when an entry
|
|
98
98
|
// can't be loaded from disk, or when any in-bundle file has an import
|
package/src/loaders/solidity.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// but is itself not included in `sources`.
|
|
7
7
|
|
|
8
8
|
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
9
10
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
10
11
|
|
|
11
12
|
const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
|
|
@@ -28,16 +29,16 @@ export function parseRemappings(content) {
|
|
|
28
29
|
}).filter(Boolean)
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
// Resolve a Solidity import specifier to a baseDir-relative POSIX path,
|
|
33
|
+
// trying three strategies in order:
|
|
34
|
+
// 1. user remappings (longest prefix wins)
|
|
35
|
+
// 2. relative path (`./...` or `../...`); traversal above the project
|
|
36
|
+
// root returns null instead of clamping
|
|
37
|
+
// 3. Node's CJS resolver via createRequire, anchored at the importing
|
|
38
|
+
// source — only when `baseDir` is provided and the specifier looks
|
|
39
|
+
// like a scoped npm package (`@scope/pkg/sub/path`)
|
|
40
|
+
// Returns null when no strategy resolves the import.
|
|
41
|
+
export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
|
|
41
42
|
let best = null
|
|
42
43
|
for (const { prefix, target } of remappings) {
|
|
43
44
|
if (specifier.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
|
|
@@ -47,13 +48,9 @@ export function resolveSolImport(specifier, fromFile, remappings) {
|
|
|
47
48
|
if (best) {
|
|
48
49
|
const target = best.target.replace(/\/$/u, '')
|
|
49
50
|
const remaining = specifier.slice(best.prefix.length).replace(/^\//u, '')
|
|
50
|
-
|
|
51
|
-
return result.replace(/^\.\//u, '')
|
|
51
|
+
return (remaining ? `${target}/${remaining}` : target).replace(/^\.\//u, '')
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// Relative import. `..` segments that would traverse above the project
|
|
55
|
-
// root return null instead of silently clamping to root — clamping would
|
|
56
|
-
// change the import target into something the source never asked for.
|
|
57
54
|
if (specifier.startsWith('.')) {
|
|
58
55
|
const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
|
|
59
56
|
const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')]
|
|
@@ -70,30 +67,41 @@ export function resolveSolImport(specifier, fromFile, remappings) {
|
|
|
70
67
|
return resolved.join('/')
|
|
71
68
|
}
|
|
72
69
|
|
|
70
|
+
// Node-style `@scope/pkg/sub/path`. createRequire is anchored at the
|
|
71
|
+
// importing source so nested node_modules resolve like Node would
|
|
72
|
+
// (a file under `node_modules/foo/` finds its deps in
|
|
73
|
+
// `foo/node_modules/` before walking up). `..` segments in the
|
|
74
|
+
// subpath are rejected so a crafted specifier can't escape the
|
|
75
|
+
// resolved package via require.resolve's own path normalization.
|
|
76
|
+
if (baseDir && specifier.startsWith('@')) {
|
|
77
|
+
const parts = specifier.split('/')
|
|
78
|
+
if (parts.length < 3) return null
|
|
79
|
+
if (parts.slice(2).includes('..')) return null
|
|
80
|
+
try {
|
|
81
|
+
const abs = createRequire(resolve(baseDir, fromFile)).resolve(specifier)
|
|
82
|
+
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
83
|
+
if (!rel.startsWith('..') && !isAbsolute(rel)) return rel
|
|
84
|
+
} catch { /* package missing, exports map blocks the subpath, … */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
return null
|
|
74
88
|
}
|
|
75
89
|
|
|
76
90
|
// Build the { sources, resolutions, missing } triple from a Map of
|
|
77
|
-
// already-loaded Solidity sources
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
// resolved or that resolved to a file that wasn't loaded into `sources`.
|
|
85
|
-
export function buildSolidityTree(sources, { remappings = [] } = {}) {
|
|
91
|
+
// already-loaded Solidity sources plus optional remappings. Imports that
|
|
92
|
+
// resolve via remappings/relative/Node win; as a final fallback an
|
|
93
|
+
// import specifier that matches a stored key verbatim (etherscan
|
|
94
|
+
// bundles, hardhat flat layouts) is accepted. `missing` lists every
|
|
95
|
+
// (spec, from) pair that could not be resolved or that resolved to a
|
|
96
|
+
// file not loaded into `sources`.
|
|
97
|
+
export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
|
|
86
98
|
const resolutions = new Map()
|
|
87
99
|
const missing = []
|
|
88
100
|
for (const [path, content] of sources) {
|
|
89
101
|
const specMap = new Map()
|
|
90
102
|
for (const spec of extractSolImports(content)) {
|
|
91
|
-
let resolved = resolveSolImport(spec, path, remappings)
|
|
103
|
+
let resolved = resolveSolImport(spec, path, { remappings, baseDir })
|
|
92
104
|
if (resolved && !sources.has(resolved)) resolved = null
|
|
93
|
-
// Verbatim fallback: many Solidity ecosystems (etherscan bundles,
|
|
94
|
-
// hardhat flat layouts) ship `@openzeppelin/...` imports as literal
|
|
95
|
-
// keys in the source map. If the remap/relative resolution didn't
|
|
96
|
-
// hit but the bundle has the specifier itself as a key, use it.
|
|
97
105
|
if (!resolved && sources.has(spec)) resolved = spec
|
|
98
106
|
if (resolved) {
|
|
99
107
|
specMap.set(spec, resolved)
|
|
@@ -107,21 +115,12 @@ export function buildSolidityTree(sources, { remappings = [] } = {}) {
|
|
|
107
115
|
return { sources, resolutions, missing }
|
|
108
116
|
}
|
|
109
117
|
|
|
110
|
-
// Walk the filesystem starting from `entries`, following
|
|
111
|
-
// imports and reading each file exactly once.
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// Specifiers that exactly match one of the explicit entries (the file
|
|
117
|
-
// list passed by the caller) are accepted as-is, even when they don't
|
|
118
|
-
// match any remapping. Solidity projects routinely write
|
|
119
|
-
// `import "src/A.sol"` and rely on solc's include-path / Foundry's
|
|
120
|
-
// remap-from-root behavior; here we don't have a true include path,
|
|
121
|
-
// but if the caller listed `src/A.sol` themselves we know the path is
|
|
122
|
-
// intentional and resolves to that file. Mirrors the `sources.has(spec)`
|
|
123
|
-
// fallback in `buildSolidityTree`, but applied during the walk so the
|
|
124
|
-
// file actually gets loaded into `sources`.
|
|
118
|
+
// Walk the filesystem starting from `entries`, following resolved
|
|
119
|
+
// imports and reading each file exactly once. Files in the same wave
|
|
120
|
+
// are read in parallel; the next wave depends on the imports
|
|
121
|
+
// discovered in the previous one. Caller-listed entries are also
|
|
122
|
+
// accepted as verbatim non-relative import targets (Foundry-style
|
|
123
|
+
// `import "src/A.sol"`).
|
|
125
124
|
export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
|
|
126
125
|
const sources = new Map()
|
|
127
126
|
const knownEntries = new Set(entries)
|
|
@@ -129,9 +128,6 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings)
|
|
|
129
128
|
const processWave = async (wave) => {
|
|
130
129
|
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
|
|
131
130
|
if (toLoad.length === 0) return
|
|
132
|
-
// A resolved path that doesn't exist on disk is treated like an
|
|
133
|
-
// unresolved import: warn and skip rather than aborting the entire
|
|
134
|
-
// walk. Other read errors (EACCES, EIO, …) still propagate.
|
|
135
131
|
const reads = await Promise.all(
|
|
136
132
|
toLoad.map(async (relPath) => {
|
|
137
133
|
try {
|
|
@@ -151,7 +147,7 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings)
|
|
|
151
147
|
const [relPath, content] = entry
|
|
152
148
|
sources.set(relPath, content)
|
|
153
149
|
for (const spec of extractSolImports(content)) {
|
|
154
|
-
let resolved = resolveSolImport(spec, relPath, remappings)
|
|
150
|
+
let resolved = resolveSolImport(spec, relPath, { remappings, baseDir })
|
|
155
151
|
if (!resolved && knownEntries.has(spec)) resolved = spec
|
|
156
152
|
if (resolved) {
|
|
157
153
|
if (!sources.has(resolved)) next.push(resolved)
|
|
@@ -210,5 +206,5 @@ export async function loadSolidity(solTxtFile) {
|
|
|
210
206
|
const entries = lines.map((l) => l.replace(/^\.\//u, ''))
|
|
211
207
|
for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
|
|
212
208
|
const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings)
|
|
213
|
-
return buildSolidityTree(sources, { remappings })
|
|
209
|
+
return buildSolidityTree(sources, { remappings, baseDir })
|
|
214
210
|
}
|