@exodus/stasis 1.0.0-beta.0 → 1.0.0-beta.10

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.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +389 -214
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
package/package.json CHANGED
@@ -1,54 +1,80 @@
1
1
  {
2
2
  "name": "@exodus/stasis",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.10",
4
4
  "description": "File-level source bundles and lockfiles",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./esbuild": "./src/esbuild.js",
8
8
  "./webpack": "./src/webpack.js",
9
+ "./rollup": "./src/rollup.js",
9
10
  "./metro": "./src/metro.js",
10
11
  "./metro-transformer": "./src/metro-transformer.js",
11
12
  "./loader": "./src/loader.js",
12
13
  "./mock": "./src/mock.js",
13
14
  "./bundle": "./src/bundle.js",
15
+ "./bundle-reader": "./src/bundle-reader.js",
14
16
  "./cmd/bundle": "./src/cmd/bundle.js",
15
17
  "./cmd/build": "./src/cmd/build.js",
16
18
  "./diff": "./src/diff.js",
17
19
  "./lockfile": "./src/lockfile.js",
18
20
  "./prune": "./src/prune.js",
19
21
  "./sbom": "./src/sbom.js",
22
+ "./audit-corrections": "./src/audit-corrections.js",
23
+ "./vfs-bundle": "./src/vfs-bundle.js",
20
24
  "./logo.svg": "./logo.svg"
21
25
  },
22
26
  "files": [
23
27
  "bin/stasis.js",
24
28
  "logo.svg",
25
- "src/apis/npm/index.js",
26
- "src/apis/npm/semver.cjs",
29
+ "src/audit-corrections.js",
27
30
  "src/audit.js",
31
+ "src/babel.js",
32
+ "src/babel-worker.cjs",
28
33
  "src/bundle.js",
29
34
  "src/bundle-from-lockfile.js",
35
+ "src/bundle-reader.js",
30
36
  "src/cmd/bundle.js",
31
37
  "src/cmd/build.js",
32
38
  "src/cmd/diff.js",
33
- "src/cmd/extract.js",
39
+ "src/cmd/github-bundle.js",
40
+ "src/cmd/output.js",
34
41
  "src/cmd/sbom.js",
35
42
  "src/diff.js",
36
43
  "src/esbuild.js",
44
+ "src/installed-commits.js",
45
+ "src/json-stream.js",
37
46
  "src/loader.js",
38
47
  "src/loader-mock.js",
39
48
  "src/loaders/bash.js",
49
+ "src/loaders/cargo.js",
50
+ "src/loaders/foundry.js",
40
51
  "src/loaders/rust.js",
41
52
  "src/loaders/solidity.js",
53
+ "src/loaders/solidity-ownership.js",
54
+ "src/loaders/toml.js",
42
55
  "src/loaders/php.js",
56
+ "src/loaders/paths.js",
43
57
  "src/lockfile.js",
44
58
  "src/metro.js",
59
+ "src/metro-resolver.js",
45
60
  "src/metro-transformer.js",
46
61
  "src/mock.js",
47
62
  "src/parse.js",
48
63
  "src/prune.js",
64
+ "src/resolve-fields.js",
65
+ "src/resolve-node.js",
66
+ "src/resolve-typescript.js",
67
+ "src/rollup.js",
49
68
  "src/sbom.js",
50
69
  "src/scan.js",
51
- "src/webpack.js"
70
+ "src/vfs-bundle.js",
71
+ "src/vfs-bundle/entries.js",
72
+ "src/vfs-bundle/github.js",
73
+ "src/vfs-bundle/prisma.js",
74
+ "src/vfs-bundle/soldeer.js",
75
+ "src/vfs-bundle/tree.js",
76
+ "src/webpack.js",
77
+ "src/why.js"
52
78
  ],
53
79
  "engines": {
54
80
  "node": ">=24.14.0"
@@ -58,26 +84,40 @@
58
84
  },
59
85
  "repository": {
60
86
  "type": "git",
61
- "url": "git+https://github.com/ExodusOSS/stasis.git",
87
+ "url": "git+https://github.com/PreventiveMeasures/stasis.git",
62
88
  "directory": "stasis"
63
89
  },
64
90
  "author": "Exodus Movement, Inc.",
65
91
  "license": "MIT",
66
92
  "bugs": {
67
- "url": "https://github.com/ExodusOSS/stasis/issues"
93
+ "url": "https://github.com/PreventiveMeasures/stasis/issues"
68
94
  },
69
- "homepage": "https://github.com/ExodusOSS/stasis#readme",
95
+ "homepage": "https://github.com/PreventiveMeasures/stasis#readme",
70
96
  "dependencies": {
71
- "@exodus/stasis-core": "1.0.0-beta.0",
72
- "@exodus/stasis-plugins": "1.0.0-beta.0",
73
- "oxc-parser": "^0.94.0"
97
+ "@exodus/bytes": "^1.16.0",
98
+ "@exodus/stasis-core": "1.0.0-beta.10",
99
+ "@exodus/stasis-plugins": "1.0.0-beta.10",
100
+ "@preventive/archive": "1.0.0-beta.4",
101
+ "@preventive/deptree": "1.0.0-alpha.11",
102
+ "@preventive/lockfile": "1.0.0-alpha.7",
103
+ "@preventive/upstream": "1.0.0-alpha.12",
104
+ "@preventive/vfs": "1.0.0-beta.4",
105
+ "oxc-parser": "^0.152.0"
74
106
  },
75
107
  "peerDependencies": {
76
- "esbuild": "*"
108
+ "@prisma/client-generator-ts": "7.10.0",
109
+ "esbuild": "*",
110
+ "flow-remove-types": "*"
77
111
  },
78
112
  "peerDependenciesMeta": {
113
+ "@prisma/client-generator-ts": {
114
+ "optional": true
115
+ },
79
116
  "esbuild": {
80
117
  "optional": true
118
+ },
119
+ "flow-remove-types": {
120
+ "optional": true
81
121
  }
82
122
  }
83
123
  }
@@ -0,0 +1,96 @@
1
+ import { satisfies, valid } from '@preventive/upstream/semver.js'
2
+
3
+ // Manual corrections to audit findings: files that must NOT count as evidence
4
+ // that a (potentially vulnerable) package's code is present. An import edge whose
5
+ // target is one of these files is not evidence the package is really used, so a
6
+ // package pulled in only through corrected files is skipped by the audit (see
7
+ // collectPackagesFromFile) -- none of its real code ships, so no advisory applies.
8
+ //
9
+ // Exported as `@exodus/stasis/audit-corrections`, as plain data, so a tool that
10
+ // lays out or reads packages without stasis -- a tree @preventive/deptree builds,
11
+ // a server reading bundles -- can apply the same corrections without its own copy.
12
+ // Each entry names a package of `ecosystem`, one audit-irrelevant `file` (relative
13
+ // to the module dir), `range` -- the semver range VERIFIED to match the rationale --
14
+ // and `integrity`, the sha512 of every distinct copy of `file` the versions in
15
+ // `range` publish, as a stasis lockfile records a file: a holder of the file's bytes
16
+ // can tell they are one of the copies verified. The correction never applies outside
17
+ // the range: a release could change the file, so the range is widened only after
18
+ // re-checking the file in the new versions, adding any new copy's integrity. Frozen,
19
+ // as the audit reads these very objects.
20
+ export const CORRECTIONS = Object.freeze([
21
+ {
22
+ // ws's browser build is a noop stub -- `module.exports = function () { throw
23
+ // new Error('ws does not work in the browser...') }` -- with none of the
24
+ // WebSocket implementation in it. browser.js ships from 6.0.0 on; every copy
25
+ // through 8.22.0 (latest at the time of writing) is one of these two, which
26
+ // differ only in a space; re-check browser.js before widening.
27
+ ecosystem: 'npm',
28
+ name: 'ws',
29
+ file: 'browser.js',
30
+ range: '<=8.22.0',
31
+ integrity: [
32
+ 'sha512-98uEc4/THUpHCA7AQwBKLHHmxV6leyGmPkDAYZsoBiigxjKL8//TIb/oUKDUJLc+s/pwdGA9LR0F9e7nZHfXUQ==', // `function ()`: 6.0.0 - 6.1.1, 7.3.1 - 8.22.0
33
+ 'sha512-F7vx6oiX9vsFoxNPTudk20Hjp7IzJePAJCYaSoX9FMJ2JaGVcFx4Bkw5W7+I3a1DsonitnosbpuG1qAMsUwH7w==', // `function()`: 6.1.2 - 7.3.0
34
+ ],
35
+ },
36
+ {
37
+ // node-fetch's browser build re-exports the environment's native fetch
38
+ // (`module.exports = globalObject.fetch`, plus Headers/Request/Response) --
39
+ // none of the node-fetch implementation, where its advisories live, is in it.
40
+ // browser.js ships only from 2.0.0 through 2.7.0 (3.x dropped it); every copy
41
+ // in the range is one of these, which differ in how they find the global.
42
+ ecosystem: 'npm',
43
+ name: 'node-fetch',
44
+ file: 'browser.js',
45
+ range: '<=2.7.0',
46
+ integrity: [
47
+ 'sha512-uCZB0X6rUVIf5lZU2NN9/Z1Fd6cPCLg4M9eduYxOgSP3QZNwi0/xSa/s6AUFtvLz4y4MK02SJihOIhDoFIzdiA==', // 2.0.0 - 2.1.1
48
+ 'sha512-toQ4RDdyjsAxTaxnaLs2AE56rXws+jMZV896U8pP0MSuKRiq8ZGKkOTMsvyCJoAJTbrgoQpr89H2n8+Z1ALcYA==', // 2.1.2
49
+ 'sha512-LOd+I1R3xYSCHFN9oj3xeN3wa7fYYx5XQKTiKkvWn4dSYVgHI2jsXZp0mJt5KlQg3ECXXh5UtpEW7kGu1dG/aQ==', // 2.2.0 - 2.2.1
50
+ 'sha512-2yo4Ysl+g09ocycwEBcN7IkSvM/KPpO9CW+1FI+0QfzvWJfaQepo+WEqn1/o5Og1weM14flcdv23hqZtxFXFUw==', // 2.3.0 - 2.6.0
51
+ 'sha512-WgHaZJVwFovVrKVySaKzxzXssn5kY8y3x8NJThodDRL9nwUxV7Q1z/RRzuLZ2lNkxabHLpG/l+vu7KQ2tGbP8g==', // 2.6.1 - 2.6.7
52
+ 'sha512-84sQFcu3uzgEsJOjIhz6tE8vInV1STCA4sX5EI9NK3Tt8n/Kb1serbKMz+YkdOAuCspHRiSUW2qV01z0wOgIXg==', // 2.6.8
53
+ 'sha512-SkBfLO20d2Ngr6nhxgh/Pt6Yqe6oW8EB99KSPq0lrW0se8jIiRF3YpOe7hHbfNWxuNCsKoZZT98uCJI9OJAa/Q==', // 2.6.9 - 2.7.0
54
+ ],
55
+ },
56
+ ].map((correction) => Object.freeze({ ...correction, integrity: Object.freeze(correction.integrity) })))
57
+
58
+ // Is `rel` (a file path relative to the module dir) audit-irrelevant for
59
+ // `name@version` of `ecosystem`? Unknown or unparsable versions are never
60
+ // corrected (fail closed: the package stays audited).
61
+ function isCorrectedFile(name, version, rel, ecosystem) {
62
+ for (const correction of CORRECTIONS) {
63
+ if (correction.ecosystem !== ecosystem || correction.name !== name || correction.file !== rel) continue
64
+ if (valid(version) && satisfies(version, correction.range)) return true
65
+ }
66
+ return false
67
+ }
68
+
69
+ // A manifest (top-level or nested) is never code: the resolution graph records
70
+ // resolver/metadata reads of `package.json` as edges (react-native scans sibling
71
+ // packages' manifests for Haste/asset resolution) and consumers record them
72
+ // alongside bundled files, but no package code ships through one. So too for the
73
+ // manifests the other ecosystems' bundles carry beside a dependency's code
74
+ // (--cargo-manifests, --manifests): its Cargo.toml and the checksums cargo vendored
75
+ // it with (a registry's copy, a git checkout's, or one whose source isn't known),
76
+ // its composer.json, a Solidity dependency's foundry.toml and remappings.
77
+ const CARGO_MANIFESTS = new Set(['Cargo.toml', 'Cargo.lock', '.cargo-checksum.json'])
78
+ const MANIFESTS = {
79
+ npm: new Set(['package.json']),
80
+ cargo: CARGO_MANIFESTS,
81
+ 'cargo-git': CARGO_MANIFESTS,
82
+ 'cargo-unknown': CARGO_MANIFESTS,
83
+ composer: new Set(['composer.json', 'composer.lock']),
84
+ soldeer: new Set(['package.json', 'foundry.toml', 'remappings.txt', 'soldeer.toml']),
85
+ github: new Set(['package.json', 'foundry.toml', 'remappings.txt', 'soldeer.toml']),
86
+ }
87
+ const isManifest = (rel, ecosystem) => MANIFESTS[ecosystem]?.has(rel.slice(rel.lastIndexOf('/') + 1)) ?? false
88
+
89
+ // Is `rel` evidence that `name@version`'s REAL code is present, of a dependency of
90
+ // `ecosystem` (npm by default)? This is the one rule every audit surface shares --
91
+ // package presence, the reason column, and the --why chain graph all count a file
92
+ // (or an edge targeting it) only when it passes. Manifests never do; CORRECTIONS'
93
+ // files don't within their verified range.
94
+ export function isEvidenceFile(name, version, rel, ecosystem = 'npm') {
95
+ return !isManifest(rel, ecosystem) && !isCorrectedFile(name, version, rel, ecosystem)
96
+ }
package/src/audit.js CHANGED
@@ -1,112 +1,337 @@
1
- import { advisories } from './apis/npm/index.js'
2
- import semver from './apis/npm/semver.cjs'
3
- import { parseFile } from './parse.js'
1
+ import { packageRepo } from '@exodus/stasis-core/bundle-util'
2
+ import { dependencyEcosystem, moduleFileKey, sameGithub } from '@exodus/stasis-core/util'
3
+ import { advisories } from '@preventive/upstream/advisories.js'
4
+ import { compareVersions, valid } from '@preventive/upstream/semver.js'
5
+ import { isEvidenceFile } from './audit-corrections.js'
6
+ import { parseFile, parseFileWithKind } from './parse.js'
7
+ import { collectWhy, invertReason } from './why.js'
4
8
 
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.
9
+ // Where advisories() asks for each ecosystem's advisories: npm's registry; OSV for crates (those
10
+ // vendored from a registry, which OSV takes for crates.io's) and Composer packages; for Soldeer
11
+ // packages and GitHub repos (Foundry's lib/ submodules), the advisories their GitHub repository
12
+ // publishes, a Soldeer package's as Soldeer names it.
13
+ const SOURCES = { npm: ['npm'], cargo: ['OSV'], composer: ['OSV'], soldeer: ['Soldeer', 'GitHub'], github: ['GitHub'] }
14
+
15
+ // Why a vendored crate that may not be crates.io's is asked nowhere: by its name, crates.io's
16
+ // advisories would be another crate's, and a private git dependency's name must not reach a public
17
+ // database at all.
18
+ const UNREGISTERED_CRATES = {
19
+ 'cargo-git': 'a crate vendored from a git repository, not crates.io',
20
+ 'cargo-unknown': 'a vendored crate with no .cargo-checksum.json, which may be a git checkout, not crates.io\'s',
21
+ }
22
+
23
+ // A dependency's version as it is audited: a GitHub repo's `.gitmodules` branch `.`, git's for the
24
+ // superproject's own branch, which the bundle does not know and advisories() takes for no branch, is
25
+ // the 0.0.0 stasis versions a repo with no version of its own by. Every advisory range covers both,
26
+ // as it does a branch name.
27
+ const versionOf = (ecosystem, version) => (ecosystem === 'github' && version === '.' ? '0.0.0' : version)
28
+
29
+ // A package's key across the audit: `name@version` for npm, as collectWhy keys its chains, and
30
+ // prefixed by the ecosystem for the others, whose names may be npm's too.
31
+ const keyOf = (ecosystem, name, version) => `${ecosystem === 'npm' ? '' : `${ecosystem}:`}${name}@${version}`
32
+
33
+ // Composer's dev versions (`dev-main`, `1.x-dev`), as Composer tells them, a `#ref` dropped: no
34
+ // advisory database lists those, and advisories() takes releases alone.
35
+ const isComposerDev = (version) => /^dev-|-dev$/iu.test(version.replace(/#.*$/su, ''))
36
+
37
+ // Why `pkg` cannot be audited, or undefined where it can.
38
+ function unaudited({ ecosystem, version }) {
39
+ if (Object.hasOwn(UNREGISTERED_CRATES, ecosystem)) return UNREGISTERED_CRATES[ecosystem]
40
+ if (!Object.hasOwn(SOURCES, ecosystem)) return `no advisories are looked up for ${ecosystem}`
41
+ if (ecosystem === 'composer' && isComposerDev(version)) return 'a Composer dev version, which no advisory database lists'
42
+ return undefined
43
+ }
44
+
45
+ // Only audit installed dependencies, `{ ecosystem, name, version }`: an npm package, a vendored
46
+ // crate, a Composer package, a Soldeer package or a GitHub repo, as the bundle tags it (a crate
47
+ // vendored from git, or from where no `.cargo-checksum.json` tells, is listed but never asked about).
48
+ //
49
+ // A package counts as present only when its REAL code is, and recorded = present:
50
+ // an artifact records exactly the files it ships or attested -- imported, entry,
51
+ // or `add`-ed (the last two have no in-edge in the resolution graph, so presence
52
+ // must NOT be derived from edges). The package is audited only if some recorded
53
+ // file is code evidence (see audit-corrections.js) -- not a corrected file (ws's
54
+ // noop browser.js stub), not a manifest (package.json, recorded for resolver /
55
+ // metadata reads; Cargo.toml). So a ws shipped only as browser.js (+ manifest) is
56
+ // skipped, while one whose real code was bundled stays. Which consumers and import
57
+ // EDGES reach that code is the reason column's concern -- collectReasons and
58
+ // why.js apply the same evidence rule per file/edge there.
59
+ //
60
+ // A package's `github` is the repository its artifact records for it (its package.json's), where
61
+ // one does; else, for an npm package a bundle carries the package.json of (one built before builds
62
+ // recorded `repo`), the one that names, read by the rule builds record it by. A GitHub repo is its
63
+ // own, and has none.
8
64
  export function collectPackagesFromFile(file) {
9
65
  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 })
66
+ const { kind, artifact } = parseFileWithKind(file)
67
+ for (const [dir, { name, version, ecosystem: tagged, repo, files }] of artifact.modules) {
68
+ const ecosystem = dependencyEcosystem(dir, tagged)
69
+ // First-party code is never sent to a public registry (leaks names, adds noise).
70
+ if (ecosystem === undefined) continue
71
+ if (!name || !version) continue
72
+ if (!Object.keys(files).some((rel) => isEvidenceFile(name, version, rel, ecosystem))) continue
73
+ let github = ecosystem === 'github' ? undefined : repo?.github
74
+ if (github === undefined && ecosystem === 'npm' && kind === 'bundle') github = manifestRepo(files['package.json'])
75
+ out.push({ ecosystem, name, version: versionOf(ecosystem, version), ...(github === undefined ? {} : { github }) })
13
76
  }
14
77
  return out
15
78
  }
16
79
 
80
+ // The GitHub repository a bundled package.json's text names (packageRepo), or undefined where it
81
+ // names none or doesn't parse. Read as Node reads one, past a byte order mark (packageJSONText),
82
+ // which a bundle keeps in the text. A lockfile holds the file's digest alone, never asked here.
83
+ function manifestRepo(text) {
84
+ if (typeof text !== 'string') return undefined
85
+ try {
86
+ return packageRepo(JSON.parse(text.replace(/^\uFEFF/u, '')))?.github
87
+ } catch {
88
+ return undefined
89
+ }
90
+ }
91
+
92
+ // Versions in semver's order where both are semver's, else as numbers in text compare.
93
+ const byNumbers = new Intl.Collator('en', { numeric: true }).compare
94
+ const byVersion = (a, b) => (valid(a) && valid(b) ? compareVersions(a, b) : byNumbers(a, b))
95
+
96
+ // Each package once, with the GitHub repository its artifacts record for its name, which
97
+ // advisories() asks instead of looking one up. It takes one repository a name, so a name recorded
98
+ // with two, by two of its versions (a package that moved) or by two artifacts, has none, and is
99
+ // looked up as before.
17
100
  export function collectPackages(files) {
18
- const seen = new Set()
19
- const out = []
101
+ const byKey = new Map()
102
+ const repos = new Map() // `ecosystem:name` -> the one repository recorded for it, or null
20
103
  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 })
104
+ for (const { github, ...pkg } of collectPackagesFromFile(file)) {
105
+ const key = keyOf(pkg.ecosystem, pkg.name, pkg.version)
106
+ if (!byKey.has(key)) byKey.set(key, pkg)
107
+ if (github === undefined) continue
108
+ const name = `${pkg.ecosystem}:${pkg.name}`
109
+ const known = repos.get(name)
110
+ // The first spelling stands.
111
+ if (known === undefined) repos.set(name, github)
112
+ else if (known !== null && !sameGithub(known, github)) repos.set(name, null)
26
113
  }
27
114
  }
28
- return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
115
+ return [...byKey.values()]
116
+ .map((pkg) => {
117
+ const github = repos.get(`${pkg.ecosystem}:${pkg.name}`)
118
+ return github ? { ...pkg, github } : pkg
119
+ })
120
+ .toSorted((a, b) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name) || byVersion(a.version, b.version))
121
+ }
122
+
123
+ // Map each audited package (keyOf it) to the bundle consumers ("reasons") that recorded its files.
124
+ // Only bundles carry a reason map ({ consumer: [file, ...] }); lockfiles omit it. Each file is
125
+ // resolved back to its owning package via the module file listing. Only evidence files attribute
126
+ // (see audit-corrections.js): a consumer that recorded nothing of a package but a corrected file or
127
+ // its manifest carries none of its real code and is NOT a reason -- e.g. webpack shipping only ws's
128
+ // noop browser.js stub, while `run` bundles the real ws.
129
+ export function collectReasons(files) {
130
+ const byPkg = new Map()
131
+ for (const file of files) {
132
+ const artifact = parseFile(file)
133
+ const fileReasons = invertReason(artifact.reason)
134
+ if (!fileReasons) continue
135
+ const fileToPkg = new Map()
136
+ for (const [dir, { name, version, ecosystem: tagged, files: modFiles }] of artifact.modules) {
137
+ const ecosystem = dependencyEcosystem(dir, tagged)
138
+ if (ecosystem === undefined || !name || !version) continue
139
+ for (const rel of Object.keys(modFiles)) {
140
+ if (!isEvidenceFile(name, version, rel, ecosystem)) continue
141
+ fileToPkg.set(moduleFileKey(dir, rel), keyOf(ecosystem, name, versionOf(ecosystem, version)))
142
+ }
143
+ }
144
+ for (const [f, consumers] of fileReasons) {
145
+ const key = fileToPkg.get(f)
146
+ if (key === undefined) continue
147
+ let set = byPkg.get(key)
148
+ if (set === undefined) byPkg.set(key, (set = new Set()))
149
+ for (const consumer of consumers) set.add(consumer)
150
+ }
151
+ }
152
+ return byPkg
29
153
  }
30
154
 
31
155
  const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 }
32
156
 
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)
157
+ // Consumer display order: bundler plugins (metro, webpack, esbuild, ...) first,
158
+ // then `run`, then `add`. Ties broken alphabetically.
159
+ const CONSUMER_RANK = { run: 1, add: 2 }
160
+ const consumerRank = (c) => CONSUMER_RANK[c] ?? 0
161
+ const byConsumerOrder = (a, b) => consumerRank(a) - consumerRank(b) || a.localeCompare(b)
162
+
163
+ // Build a row's `reason` cell by unioning over exactly the affected versions it
164
+ // covers. Without `--why` that's the `, `-joined set of bundle consumers; with
165
+ // `--why` it's the newline-joined `consumer: a -> b -> c` import-path lines
166
+ // (which REPLACE the consumer list). Consumers are ordered plugins -> run -> add.
167
+ // `reasonFilter`, when set, narrows the cell to a single consumer: the `--why`
168
+ // chains are already filtered upstream (see collectWhy), so only the consumer
169
+ // list needs pruning here. collectWhy follows npm's import graph alone, so an
170
+ // advisory of another ecosystem keeps its consumer list under `--why` too.
171
+ function reasonCell({ ecosystem, name, versions: affected }, reasonsByPkg, whyByPkg, reasonFilter) {
172
+ const chains = whyByPkg !== null && ecosystem === 'npm'
173
+ const parts = new Set()
174
+ const source = chains ? whyByPkg : reasonsByPkg
175
+ for (const v of affected) {
176
+ for (const p of source.get(keyOf(ecosystem, name, v)) ?? []) parts.add(p)
177
+ }
178
+ // --why: group `consumer: path` lines by consumer and order the groups
179
+ // plugins -> run -> add (this also re-unites a consumer's lines when they were
180
+ // split across affected versions); within a group collectWhy's compressed order
181
+ // is preserved. Otherwise: the `, `-joined consumer set in the same order.
182
+ if (chains) {
183
+ const byConsumer = Map.groupBy(parts, (line) => {
184
+ const i = line.indexOf(': ')
185
+ return i === -1 ? '' : line.slice(0, i)
186
+ })
187
+ return [...byConsumer.keys()].toSorted(byConsumerOrder).flatMap((c) => byConsumer.get(c)).join('\n')
38
188
  }
189
+ const consumers = reasonFilter ? [...parts].filter((c) => c === reasonFilter) : [...parts]
190
+ return consumers.toSorted(byConsumerOrder).join(', ')
191
+ }
192
+
193
+ // `found` is what @preventive/upstream's advisories() answers: one row per advisory range, whose
194
+ // `versions` are the audited versions it covers (never empty -- a range covering none is dropped
195
+ // there, so the table and exit code reflect only real hits).
196
+ export function flattenAdvisories(found, reasonsByPkg = new Map(), whyByPkg = null, reasonFilter = null) {
39
197
  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
- }
198
+ for (const adv of found) {
199
+ const reason = reasonCell(adv, reasonsByPkg, whyByPkg, reasonFilter)
200
+ // --reason keeps only advisories tied to that consumer: once the cell is
201
+ // narrowed to it, an empty cell means this package isn't related to it.
202
+ if (reasonFilter && reason === '') continue
203
+ rows.push({
204
+ ecosystem: adv.ecosystem,
205
+ package: adv.name,
206
+ installed: adv.versions.join(', '),
207
+ vulnerable: adv.range ?? '',
208
+ severity: adv.severity ?? '',
209
+ title: adv.title ?? '',
210
+ id: adv.id,
211
+ reason,
212
+ })
61
213
  }
62
214
  rows.sort((a, b) => {
63
215
  const sa = SEVERITY_ORDER[a.severity] ?? 99
64
216
  const sb = SEVERITY_ORDER[b.severity] ?? 99
65
217
  if (sa !== sb) return sa - sb
66
218
  if (a.package !== b.package) return a.package < b.package ? -1 : 1
219
+ if (a.ecosystem !== b.ecosystem) return a.ecosystem < b.ecosystem ? -1 : 1
67
220
  return a.title < b.title ? -1 : 1
68
221
  })
69
222
  return rows
70
223
  }
71
224
 
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.
225
+ // Strip line breaks (LF/CR/CRLF, U+2028/U+2029) from advisory titles so they can't break the box layout.
74
226
  const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ')
75
227
 
76
- export function formatTable(rows, columns) {
228
+ // Split a value into the physical lines a cell occupies. Non-multiline columns
229
+ // collapse every line break to a space via `cell` (one line). A column named in
230
+ // `multiline` (the `--why` reason column) keeps intentional `\n` breaks -- one
231
+ // physical line per entry -- while still flattening stray CR/separators in each.
232
+ const cellLines = (v, multiline) =>
233
+ multiline ? String(v ?? '').split('\n').map((s) => cell(s)) : [cell(v)]
234
+
235
+ export function formatTable(rows, columns, { multiline = [] } = {}) {
77
236
  if (rows.length === 0) return ''
78
- const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)))
237
+ const ml = new Set(multiline)
238
+ const linesOf = (obj) => columns.map((c) => cellLines(obj[c], ml.has(c)))
239
+ const header = columns.map((c) => [c])
240
+ const body = rows.map((r) => linesOf(r))
241
+ const widths = columns.map((c, i) =>
242
+ Math.max(c.length, ...body.map((cells) => Math.max(0, ...cells[i].map((l) => l.length))))
243
+ )
79
244
  const pad = (s, w) => s.padEnd(w)
80
245
  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(' │ ') + ' │'
246
+ // A row spans as many physical lines as its tallest cell; shorter cells pad
247
+ // out with blanks so the borders stay aligned.
248
+ const render = (cells) => {
249
+ const height = Math.max(...cells.map((c) => c.length))
250
+ const out = []
251
+ for (let i = 0; i < height; i++) {
252
+ out.push('│ ' + cells.map((c, j) => pad(c[i] ?? '', widths[j])).join(' │ ') + ' │')
253
+ }
254
+ return out.join('\n')
255
+ }
82
256
  return [
83
257
  line('┌', '┬', '┐', '─'),
84
- row(columns),
258
+ render(header),
85
259
  line('├', '┼', '┤', '─'),
86
- ...rows.map((r) => row(columns.map((c) => r[c]))),
260
+ ...body.map(render),
87
261
  line('└', '┴', '┘', '─'),
88
262
  ].join('\n')
89
263
  }
90
264
 
91
- export async function audit(files) {
265
+ // `repoAdvisories` and `github` (a @preventive/upstream/github.js client) go to advisories() as they
266
+ // are; a Soldeer package or a GitHub repo needs the client, their repository being their only source.
267
+ // A package that cannot be audited is `skipped`, with why (`because`), and asked for nothing.
268
+ export async function audit(files, { why = false, whyDeep = false, whyFull = false, reason = null, repoAdvisories = false, github } = {}) {
269
+ // --why-deep and --why-full imply --why. Deep keeps every chain instead of the
270
+ // default pruning of chains whose full suffix is already a chain (see
271
+ // collectWhy/dropSuffixed); full spells chains out with no `...` collapse.
272
+ why = why || whyDeep || whyFull
92
273
  const packages = collectPackages(files)
93
- if (packages.length === 0) {
94
- return { packages, advisories: {}, rows: [] }
274
+ const skipped = packages.flatMap((pkg) => {
275
+ const because = unaudited(pkg)
276
+ return because === undefined ? [] : [{ ...pkg, because }]
277
+ })
278
+ const asked = packages.filter((pkg) => unaudited(pkg) === undefined)
279
+ if (asked.length === 0) {
280
+ return { packages, skipped, advisories: [], rows: [], why }
281
+ }
282
+ let result
283
+ try {
284
+ result = await advisories(asked.map(({ version, ...pkg }) => ({ ...pkg, versions: [version] })), { repoAdvisories, github })
285
+ } catch (cause) {
286
+ // Refused input or a malformed answer is an assertion that says so itself; a transport or
287
+ // HTTP failure gets the context of which request it was.
288
+ if (cause?.code === 'ERR_ASSERTION') throw cause
289
+ const sources = new Set([...asked.flatMap((pkg) => SOURCES[pkg.ecosystem]), ...(repoAdvisories ? ['GitHub'] : [])])
290
+ throw new Error(`${[...sources].join('/')} advisories request failed: ${cause.message}`, { cause })
291
+ }
292
+ // `--why` REPLACES the consumer list with per-consumer import paths, so only
293
+ // one of the two is computed for npm's advisories, the only ones collectWhy has
294
+ // paths for: another ecosystem's keep their consumers. Restrict the (potentially
295
+ // expensive) path search to the packages that actually carry an advisory.
296
+ // `reason` (--reason) narrows both the paths (in collectWhy) and the consumer
297
+ // list (in flattenAdvisories) to a single consumer, dropping advisories
298
+ // unrelated to it.
299
+ let rows
300
+ if (why) {
301
+ const npm = result.filter((adv) => adv.ecosystem === 'npm')
302
+ const targetKeys = new Set(npm.flatMap((adv) => adv.versions.map((v) => keyOf(adv.ecosystem, adv.name, v))))
303
+ const reasons = npm.length < result.length ? collectReasons(files) : undefined
304
+ rows = flattenAdvisories(result, reasons, collectWhy(files, targetKeys, reason, { deep: whyDeep, full: whyFull }), reason)
305
+ } else {
306
+ rows = flattenAdvisories(result, collectReasons(files), null, reason)
95
307
  }
96
- const result = await advisories(packages)
97
- const rows = flattenAdvisories(result, packages)
98
- return { packages, advisories: result, rows }
308
+ return { packages, skipped, advisories: result, rows, why, whyDeep, whyFull, reason }
99
309
  }
100
310
 
101
- export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) {
102
- err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
311
+ // `10 alerts, 1 critical, 5 high, 3 moderate, 1 low`: the rows, then each severity present, in the
312
+ // rows' own order (most severe first); a row without one is `unrated`, so the parts add up.
313
+ function alertStats(rows) {
314
+ const bySeverity = Map.groupBy(rows, (r) => r.severity || 'unrated')
315
+ return [`${rows.length} alert${rows.length === 1 ? '' : 's'}`, ...[...bySeverity].map(([severity, list]) => `${list.length} ${severity}`)].join(', ')
316
+ }
317
+
318
+ export function printAuditReport({ packages, skipped = [], rows, why = false, reason = null }, { out = process.stdout, err = process.stderr } = {}) {
319
+ const scanned = `Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}`
103
320
  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')
321
+ err.write(`${scanned}\nNo dependencies found in the input files\n`)
109
322
  return
110
323
  }
111
- out.write(formatTable(rows, ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']) + '\n')
324
+ err.write(`${scanned}: ${alertStats(rows)}\n`)
325
+ for (const pkg of skipped) err.write(`Not audited: ${pkg.ecosystem} ${pkg.name}@${pkg.version}, ${pkg.because}\n`)
326
+ if (rows.length === 0) return
327
+ // The ecosystem column only where one is not npm's, which the others were all once.
328
+ const columns = ['severity', ...(rows.some((r) => r.ecosystem !== 'npm') ? ['ecosystem'] : []), 'package', 'installed', 'vulnerable', 'title', 'id']
329
+ // Surface the reason column only when some advisory has provenance -- bundle
330
+ // consumers, or (with --why) import paths. Under --reason WITHOUT --why every
331
+ // cell is just the filter value repeated, so drop the column then; --why still
332
+ // earns it (the import chains differ per row).
333
+ if (rows.some((r) => r.reason) && !(reason && !why)) columns.splice(columns.indexOf('installed') + 1, 0, 'reason')
334
+ // Under --why the reason cell is a list of `consumer: path` lines; let it span
335
+ // multiple physical rows instead of collapsing to one.
336
+ out.write(formatTable(rows, columns, { multiline: why ? ['reason'] : [] }) + '\n')
112
337
  }