@exodus/stasis 1.0.0-alpha.2 → 1.0.0-beta.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/src/audit.js ADDED
@@ -0,0 +1,112 @@
1
+ import { advisories } from './apis/npm/index.js'
2
+ import semver from './apis/npm/semver.cjs'
3
+ import { parseFile } from './parse.js'
4
+
5
+ // Only audit installed dependencies; workspace/first-party packages live under
6
+ // non-`node_modules` keys in .modules (Lockfile/Bundle merge sources in) and
7
+ // shouldn't be sent to the public registry — they leak names and produce noise.
8
+ export function collectPackagesFromFile(file) {
9
+ const out = []
10
+ for (const [dir, { name, version }] of parseFile(file).modules) {
11
+ if (!dir.includes('node_modules')) continue
12
+ if (name && version) out.push({ name, version })
13
+ }
14
+ return out
15
+ }
16
+
17
+ export function collectPackages(files) {
18
+ const seen = new Set()
19
+ const out = []
20
+ for (const file of files) {
21
+ for (const { name, version } of collectPackagesFromFile(file)) {
22
+ const key = `${name}@${version}`
23
+ if (seen.has(key)) continue
24
+ seen.add(key)
25
+ out.push({ name, version })
26
+ }
27
+ }
28
+ return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
29
+ }
30
+
31
+ const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 }
32
+
33
+ export function flattenAdvisories(result, packages = []) {
34
+ const installed = new Map()
35
+ for (const { name, version } of packages) {
36
+ if (!installed.has(name)) installed.set(name, [])
37
+ installed.get(name).push(version)
38
+ }
39
+ const rows = []
40
+ for (const [pkg, list] of Object.entries(result)) {
41
+ if (!Array.isArray(list)) continue
42
+ const installedVersions = installed.get(pkg) ?? []
43
+ for (const adv of list) {
44
+ const range = adv.vulnerable_versions ?? ''
45
+ const affected = range
46
+ ? installedVersions.filter((v) => semver.satisfies(v, range))
47
+ : installedVersions
48
+ // npm occasionally returns advisories whose vulnerable range matches none
49
+ // of the versions we submitted; drop them so the table (and the CLI exit
50
+ // code) reflect only real hits.
51
+ if (installedVersions.length > 0 && affected.length === 0) continue
52
+ rows.push({
53
+ package: pkg,
54
+ installed: affected.join(', '),
55
+ vulnerable: range,
56
+ severity: adv.severity ?? '',
57
+ title: adv.title ?? '',
58
+ url: adv.url ?? '',
59
+ })
60
+ }
61
+ }
62
+ rows.sort((a, b) => {
63
+ const sa = SEVERITY_ORDER[a.severity] ?? 99
64
+ const sb = SEVERITY_ORDER[b.severity] ?? 99
65
+ if (sa !== sb) return sa - sb
66
+ if (a.package !== b.package) return a.package < b.package ? -1 : 1
67
+ return a.title < b.title ? -1 : 1
68
+ })
69
+ return rows
70
+ }
71
+
72
+ // Line breaks in npm advisory titles would otherwise break the box layout.
73
+ // Covers LF, bare CR (and CRLF), and U+2028 / U+2029 line/paragraph separators.
74
+ const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ')
75
+
76
+ export function formatTable(rows, columns) {
77
+ if (rows.length === 0) return ''
78
+ const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)))
79
+ const pad = (s, w) => s.padEnd(w)
80
+ const line = (l, m, r, fill) => l + widths.map((w) => fill.repeat(w + 2)).join(m) + r
81
+ const row = (vals) => '│ ' + vals.map((v, i) => pad(cell(v), widths[i])).join(' │ ') + ' │'
82
+ return [
83
+ line('┌', '┬', '┐', '─'),
84
+ row(columns),
85
+ line('├', '┼', '┤', '─'),
86
+ ...rows.map((r) => row(columns.map((c) => r[c]))),
87
+ line('└', '┴', '┘', '─'),
88
+ ].join('\n')
89
+ }
90
+
91
+ export async function audit(files) {
92
+ const packages = collectPackages(files)
93
+ if (packages.length === 0) {
94
+ return { packages, advisories: {}, rows: [] }
95
+ }
96
+ const result = await advisories(packages)
97
+ const rows = flattenAdvisories(result, packages)
98
+ return { packages, advisories: result, rows }
99
+ }
100
+
101
+ export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) {
102
+ err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
103
+ if (packages.length === 0) {
104
+ err.write('No node_modules entries found in the input files\n')
105
+ return
106
+ }
107
+ if (rows.length === 0) {
108
+ err.write('No advisories found\n')
109
+ return
110
+ }
111
+ out.write(formatTable(rows, ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']) + '\n')
112
+ }
@@ -0,0 +1,65 @@
1
+ import { isUtf8 } from 'node:buffer'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+
5
+ import { Bundle } from '@exodus/stasis-core/bundle'
6
+ import { sha512integrity } from '@exodus/stasis-core/state-util'
7
+ import { moduleFileKey } from '@exodus/stasis-core/util'
8
+
9
+ // Reconstruct an in-memory Bundle from a lockfile by reading every file it attests
10
+ // from disk and verifying each against the lockfile's SRI digest -- the exact inverse
11
+ // of `extract`'s lockfileFromBundle. A changed or tampered file fails closed here (the
12
+ // digest won't match), so building from a lockfile is as trustworthy as a
13
+ // `stasis run --lock=frozen` replay: the build only ever sees bytes the lockfile
14
+ // vouches for. `root` is the project root the lockfile's paths are relative to.
15
+ //
16
+ // Directory captures (`stasis run --fs` readdir listings, format 'directory') are
17
+ // skipped: they key a listing at a directory's own path, not a file esbuild could load.
18
+ export function bundleFromLockfile(lockfile, { root }) {
19
+ if (!lockfile.imports) throw new Error('stasis: lockfile has no recorded import graph')
20
+ const formats = lockfile.formats ?? new Map()
21
+ const modules = new Map()
22
+ for (const [dir, { name, version, ecosystem, files }] of lockfile.modules) {
23
+ const out = Object.create(null)
24
+ for (const [rel, integrity] of Object.entries(files)) {
25
+ const file = moduleFileKey(dir, rel)
26
+ const format = formats.get(file)
27
+ if (format === 'directory') continue
28
+ const abs = resolve(root, file)
29
+ let bytes
30
+ try {
31
+ bytes = readFileSync(abs)
32
+ } catch (cause) {
33
+ throw new Error(`stasis: file attested by the lockfile is missing on disk: ${file}`, { cause })
34
+ }
35
+ // The lockfile digests raw on-disk bytes; verify against them before trusting
36
+ // the file. (Code and 'resource' files hash identically whether viewed as a
37
+ // string or bytes, since Node hashes a string as its UTF-8 bytes.)
38
+ const actual = sha512integrity(bytes)
39
+ if (actual !== integrity) {
40
+ throw new Error(`stasis: ${file} does not match the lockfile (expected ${integrity}, got ${actual})`)
41
+ }
42
+ // Store content the way a Bundle does: 'resource:base64' as base64 text,
43
+ // everything else (code, 'resource') as a raw UTF-8 string. A non-base64 entry is
44
+ // digested AND served as UTF-8 text, so a non-UTF-8 byte would transcode lossily
45
+ // (U+FFFD), diverging the served content from the verified bytes -- mirror the
46
+ // capture side's isUtf8 guard and fail closed instead.
47
+ if (format === 'resource:base64') {
48
+ out[rel] = bytes.toString('base64')
49
+ } else {
50
+ if (!isUtf8(bytes)) throw new Error(`stasis: ${file} is recorded as '${format ?? 'code'}' but is not valid UTF-8`)
51
+ out[rel] = bytes.toString('utf8')
52
+ }
53
+ }
54
+ modules.set(dir, ecosystem === undefined
55
+ ? { name, version, files: out }
56
+ : { name, version, ecosystem, files: out })
57
+ }
58
+ return new Bundle({
59
+ config: lockfile.config,
60
+ entries: lockfile.entries,
61
+ modules,
62
+ imports: lockfile.imports,
63
+ formats,
64
+ })
65
+ }
package/src/bundle.js CHANGED
@@ -1,217 +1,4 @@
1
- import {
2
- assert,
3
- fileMapToObject,
4
- fileSetToObject,
5
- fromEntries,
6
- objectToMaps,
7
- sortPaths,
8
- splitNodeModulesPath,
9
- } from './util.js'
10
-
11
- const VERSION = 1
12
- const LEGACY_VERSION = 0
13
-
14
- const normalize = ({ name, version, files }) =>
15
- ({ name, version, files: fromEntries(Object.entries(files)) })
16
-
17
- const inferModuleDir = (path) =>
18
- splitNodeModulesPath(path) ?? { dir: '.', rel: path, name: null }
19
-
20
- // Bundle handles the JSON shape of stasis.code.br / stasis.resources.br;
21
- // callers (State) are responsible for the brotli wrapper around the JSON
22
- // text. On-disk version:
23
- // v0 — legacy flat layout: top-level `sources` (or `resources`) keyed by
24
- // project-relative path. No entries/modules metadata.
25
- // v1 — current layout, mirrors stasis.lock.json: per-package buckets under
26
- // `sources` (workspace) and `modules` (node_modules) with name+version+files,
27
- // plus `entries`. File payloads carry bytes (UTF-8 source strings or base64
28
- // resource blobs) instead of SRI digests.
29
- // We load both v0 and v1; we always serialize v1. v0 is converted on load to
30
- // the same in-memory shape — paths are regrouped by package dir via
31
- // `inferModuleDir` (best-effort, last-wins). name/version stay null since v0
32
- // records neither, which State uses as a marker to skip metadata cross-checks.
33
- export class Bundle {
34
- static VERSION = VERSION
35
-
36
- // The on-disk version this instance represents. Defaults to VERSION; parse*
37
- // sets it to whatever the file declared (LEGACY_VERSION for v0 input).
38
- // Serialization always writes the current VERSION regardless — loading v0 and
39
- // re-saving promotes the bundle to v1.
40
- version = VERSION
41
- config
42
- entries
43
- modules
44
- formats
45
- imports
46
-
47
- constructor({ config = { scope: 'full' }, entries, modules, formats, imports, version = VERSION } = {}) {
48
- assert([LEGACY_VERSION, VERSION].includes(version))
49
- assert(['node_modules', 'full'].includes(config.scope))
50
- this.version = version
51
- this.config = config
52
- this.entries = entries ?? new Set()
53
- this.modules = modules ?? new Map()
54
- this.formats = formats ?? new Map()
55
- this.imports = imports ?? new Map()
56
- }
57
-
58
- // Flat project-relative view of file contents collected from this.modules.
59
- // Values are UTF-8 source strings for code bundles, base64 for resource bundles.
60
- get sources() {
61
- const m = new Map()
62
- for (const [dir, { files }] of this.modules) {
63
- for (const [rel, content] of Object.entries(files)) {
64
- m.set(dir === '.' ? rel : `${dir}/${rel}`, content)
65
- }
66
- }
67
- return m
68
- }
69
-
70
- static parseCode(text) {
71
- const json = JSON.parse(text)
72
- assert(json.version === VERSION || json.version === LEGACY_VERSION)
73
- assert(['node_modules', 'full'].includes(json.config?.scope))
74
- assert(json.formats)
75
- assert(json.imports)
76
-
77
- const modules = new Map()
78
- let entries = new Set()
79
-
80
- if (json.version === VERSION) {
81
- const full = json.config.scope === 'full'
82
- if (json.modules !== undefined) {
83
- assert(typeof json.modules === 'object' && json.modules !== null)
84
- for (const [dir, info] of Object.entries(json.modules)) {
85
- assert(dir.includes('node_modules'))
86
- assert(!dir.startsWith('..'))
87
- assert(info?.name && info.version && info.files)
88
- modules.set(dir, normalize(info))
89
- }
90
- }
91
- if (full) {
92
- assert(Array.isArray(json.entries))
93
- // Empty entries in v1 + full would let `state.assertEntry` skip its check
94
- // (it short-circuits on entries.size === 0 for v0+no-lockfile compatibility).
95
- // Forbid the shape at parse time so tampered bundles can't exploit that path.
96
- assert(json.entries.length > 0, 'v1 bundle with scope=full must have at least one entry')
97
- assert(json.sources && typeof json.sources === 'object')
98
- entries = new Set(json.entries)
99
- for (const [dir, info] of Object.entries(json.sources)) {
100
- assert(!dir.includes('node_modules'))
101
- assert(!dir.startsWith('..'))
102
- assert(info?.name && info.version && info.files)
103
- modules.set(dir, normalize(info))
104
- }
105
- } else {
106
- assert(json.entries === undefined)
107
- assert(json.sources === undefined)
108
- }
109
- for (const [, { files }] of modules) {
110
- for (const rel of Object.keys(files)) assert(!rel.startsWith('..'))
111
- }
112
- } else {
113
- // v0 legacy: flat sources at top level, regrouped by inferred module dir.
114
- assert(json.sources)
115
- for (const [path, content] of Object.entries(json.sources)) {
116
- assert(!path.startsWith('..'))
117
- const { dir, rel, name } = inferModuleDir(path)
118
- assert(!dir.startsWith('..') && !rel.startsWith('..'))
119
- if (!modules.has(dir)) modules.set(dir, { name, version: null, files: Object.create(null) })
120
- modules.get(dir).files[rel] = content
121
- }
122
- }
123
-
124
- return new Bundle({
125
- version: json.version,
126
- config: json.config,
127
- entries,
128
- modules,
129
- formats: new Map(Object.entries(json.formats)),
130
- imports: objectToMaps(json.imports),
131
- })
132
- }
133
-
134
- static parseResources(text) {
135
- const json = JSON.parse(text)
136
- assert(json.version === VERSION || json.version === LEGACY_VERSION)
137
-
138
- const modules = new Map()
139
- let config = { scope: 'full' }
140
-
141
- if (json.version === VERSION) {
142
- assert(['node_modules', 'full'].includes(json.config?.scope))
143
- config = json.config
144
- const full = config.scope === 'full'
145
- if (json.modules !== undefined) {
146
- assert(typeof json.modules === 'object' && json.modules !== null)
147
- for (const [dir, info] of Object.entries(json.modules)) {
148
- assert(dir.includes('node_modules'))
149
- assert(!dir.startsWith('..'))
150
- assert(info?.name && info.version && info.files)
151
- modules.set(dir, normalize(info))
152
- }
153
- }
154
- if (full && json.sources !== undefined) {
155
- assert(typeof json.sources === 'object' && json.sources !== null)
156
- for (const [dir, info] of Object.entries(json.sources)) {
157
- assert(!dir.includes('node_modules'))
158
- assert(!dir.startsWith('..'))
159
- assert(info?.name && info.version && info.files)
160
- modules.set(dir, normalize(info))
161
- }
162
- } else if (!full) {
163
- assert(json.sources === undefined)
164
- }
165
- for (const [, { files }] of modules) {
166
- for (const rel of Object.keys(files)) assert(!rel.startsWith('..'))
167
- }
168
- } else {
169
- // v0 legacy: flat resources at top level, regrouped by inferred module dir.
170
- assert(json.resources)
171
- for (const [path, content] of Object.entries(json.resources)) {
172
- assert(!path.startsWith('..'))
173
- const { dir, rel, name } = inferModuleDir(path)
174
- assert(!dir.startsWith('..') && !rel.startsWith('..'))
175
- if (!modules.has(dir)) modules.set(dir, { name, version: null, files: Object.create(null) })
176
- modules.get(dir).files[rel] = content
177
- }
178
- }
179
-
180
- return new Bundle({ version: json.version, config, modules })
181
- }
182
-
183
- #groupedFromModules() {
184
- const moduleEntries = []
185
- const sourceEntries = []
186
- for (const [dir, { name, version, files }] of this.modules) {
187
- if (Object.keys(files).length === 0) continue
188
- const inNodeModules = dir.includes('node_modules')
189
- if (inNodeModules) assert(name && version && files)
190
- const sorted = fromEntries(Object.entries(files).toSorted((a, b) => sortPaths(a[0], b[0])))
191
- const target = inNodeModules ? moduleEntries : sourceEntries
192
- target.push([dir, { name, version, files: sorted }])
193
- }
194
- moduleEntries.sort((a, b) => sortPaths(a[0], b[0]))
195
- sourceEntries.sort((a, b) => sortPaths(a[0], b[0]))
196
- return { modules: fromEntries(moduleEntries), sources: fromEntries(sourceEntries) }
197
- }
198
-
199
- serializeCode() {
200
- const entries = fileSetToObject(this.entries)
201
- const { modules, sources } = this.#groupedFromModules()
202
- const formats = fileMapToObject(this.formats)
203
- const imports = fileMapToObject(this.imports)
204
- const data = { version: VERSION, config: this.config }
205
- if (this.config.scope === 'full') Object.assign(data, { entries, sources })
206
- Object.assign(data, { modules, formats, imports })
207
- return JSON.stringify(data, undefined, 2)
208
- }
209
-
210
- serializeResources() {
211
- const { modules, sources } = this.#groupedFromModules()
212
- const data = { version: VERSION, config: this.config }
213
- if (this.config.scope === 'full') Object.assign(data, { sources })
214
- Object.assign(data, { modules })
215
- return JSON.stringify(data, undefined, 2)
216
- }
217
- }
1
+ // Public-export adapter: backs `@exodus/stasis/bundle`. The implementation lives in
2
+ // @exodus/stasis-core; internal tooling and tests import `@exodus/stasis-core/bundle`
3
+ // directly, so this file exists only to back the package's `exports` map.
4
+ export * from '@exodus/stasis-core/bundle'