@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/stasis",
3
- "version": "1.0.0-alpha.2",
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
@@ -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
- // Returns a candidate resolved path, or null if the specifier doesn't match
32
- // any remapping and isn't a relative import. The caller is responsible for
33
- // checking whether the resolved path actually exists in `sources` (the
34
- // etherscan bundle's cnames are exact-match keys — `@openzeppelin/…` lives
35
- // alongside `contracts/Foo.sol` — so a non-relative, non-remapped specifier
36
- // may still be a hit when matched verbatim against the bundle). Logging
37
- // "Missing import" also lives at the callsite because only the callsite
38
- // knows whether that verbatim fallback matched.
39
- export function resolveSolImport(specifier, fromFile, remappings) {
40
- // Try remappings (longest prefix match)
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
- const result = remaining ? `${target}/${remaining}` : target
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 (any key format — relative paths for
78
- // disk-loaded projects, `${address}/${cname}` for etherscan, etc.) plus
79
- // optional remappings. Imports that resolve via remappings/relative paths
80
- // win; as a fallback we also accept an import specifier that matches a
81
- // stored key verbatim (etherscan bundles imports like
82
- // `@openzeppelin/contracts/.../IERC20.sol` which is itself a stored
83
- // cname). `missing` lists every (spec, from) pair that could not be
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 relative/remapped
111
- // imports and reading each file exactly once. Returns a Map<relPath, content>
112
- // suitable for passing into buildSolidityTree. Files in the same wave are
113
- // read in parallel; subsequent waves depend on the imports discovered in
114
- // the previous one.
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
  }