@preventive/triage 1.0.0-alpha.36 → 1.0.0-alpha.38
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/README.md +16 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +6 -2
- package/common/bundle-metadata.js +63 -20
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-source-links.js +7 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +3 -1
- package/common/bundle-sources.js +47 -19
- package/common/code-language.js +16 -2
- package/common/default-scan-models.ts +2 -1
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/github-pr.ts +1 -1
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +1 -5
- package/common/managed/routes.js +123 -12
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +1 -1
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-5FKRIJGF.js +1 -0
- package/out/chunk-6Z5XLSPJ.js +1 -0
- package/out/chunk-ABW5FVSN.js +1 -0
- package/out/chunk-BJKRVROW.js +1 -0
- package/out/chunk-BYUEDJOM.js +7 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DCQX2J3C.js +223 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-F4OMDNEY.js +1 -0
- package/out/chunk-FU4MTW7F.js +1 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GMFXFEUA.js +2 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-HIV7SVI7.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-JAKWIZWL.js +2 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OJ5DGP5E.js +33 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-SD2WJAXN.js +48 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZPD5R5RP.js +3 -0
- package/out/client-managed-import.js +3 -87
- package/out/client-managed.js +26 -338
- package/out/client-sync.js +8 -14
- package/out/graph.js +17 -30
- package/out/index.html +34 -0
- package/out/prism.js +6 -2
- package/out/terminal.js +241 -257
- package/out/view.css +1 -1
- package/out/view.js +76 -520
- package/package.json +88 -7
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +164 -0
- package/risk/files/runs-code.js +329 -0
- package/risk/files/script.js +112 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +222 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +33 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-e2e/http.ts +7 -7
- package/server-e2e/npm-proxy.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
- package/server-e2e/objstore/handlers.ts +1 -1
- package/server-e2e/objstore/tokens.ts +1 -1
- package/server-e2e/sign.ts +1 -1
- package/server-managed/bundle-advisories.ts +68 -11
- package/server-managed/bundle-build-worker.js +1 -1
- package/server-managed/bundle-build.ts +6 -2
- package/server-managed/bundle-cache.ts +37 -4
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-summary-cache.ts +24 -10
- package/server-managed/config.ts +4 -0
- package/server-managed/db-methods.ts +98 -11
- package/server-managed/db-neon.ts +39 -4
- package/server-managed/db-schema.ts +14 -2
- package/server-managed/db-table-names.ts +1 -1
- package/server-managed/db.ts +7 -3
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-issue-oauth.ts +5 -6
- package/server-managed/github-metadata.ts +115 -0
- package/server-managed/github-oauth.ts +38 -2
- package/server-managed/http-response.ts +26 -0
- package/server-managed/http.ts +943 -52
- package/server-managed/index.ts +11 -5
- package/server-managed/kept.ts +28 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +78 -7
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/report-sources.ts +13 -2
- package/server-managed/repository-browser.ts +19 -5
- package/server-managed/repository-risk.ts +413 -0
- package/server-managed/session.ts +16 -2
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage-encryption.ts +5 -3
- package/server-managed/storage-payload.ts +4 -0
- package/server-managed/storage-stores.ts +2 -0
- package/server-managed/storage.ts +6 -3
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-feed.ts +1 -1
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +25 -4
- package/server-managed/upstream-cache.ts +147 -5
- package/server-managed/view-as.ts +58 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// How a file's text reads, for a reviewer: its category (binary, a source
|
|
2
|
+
// map, code with unexpectedly long lines or an inline map, minified, else
|
|
3
|
+
// ASCII or UTF-8), its control and invisible characters, its longest lines,
|
|
4
|
+
// an obfuscator's fingerprint, a large code file, and a file become
|
|
5
|
+
// unreadable between two versions.
|
|
6
|
+
import { CONTROLS, npmTextEncoding } from './characters.js'
|
|
7
|
+
import { MINIFIED_AVERAGE, minifiable, minifiedAverage } from './script.js'
|
|
8
|
+
|
|
9
|
+
// Lines longer than anyone writes by hand.
|
|
10
|
+
export const NPM_LONG_LINE = 1000
|
|
11
|
+
// Prose, which wraps where it is read: its long lines are paragraphs. By its
|
|
12
|
+
// extension (text, Markdown and MDX, and the markup GitHub renders a README
|
|
13
|
+
// from: `README.org`, `README.rdoc`), or its name alone (`LICENSE-MIT`,
|
|
14
|
+
// `CHANGES`; a dot in it only a version's, `LICENSE-LGPL2.1`,
|
|
15
|
+
// `CHANGES.2.0`; a prose extension after it is one), not code named alike
|
|
16
|
+
// (`lib/history.js`, `dist/notice.min.js`), which can be minified as any.
|
|
17
|
+
export const NPM_PROSE = /(?:\.(?:md|markdown|mdown|mkdn|mdx|txt|rst|adoc|asciidoc|asc|textile|rdoc|org|creole|mediawiki|wiki|pod)|(?:^|\/)(?:licen[cs]e|copying|notice|authors|contributors|readme|changelog|changes|history)(?:-[^/.]*)?(?:\.\d[^/.]*)*)$/iu
|
|
18
|
+
const SOURCE_MAP_COMMENT = /^\s*(?:\/\/|\/\*)[#@] sourceMappingURL=/u
|
|
19
|
+
// A line that is all one, the map in it as a data: URL, base64 or
|
|
20
|
+
// percent-encoded: not code that writes one.
|
|
21
|
+
const INLINE_SOURCE_MAP = /^\s*(?:\/\/|\/\*)[#@] sourceMappingURL=data:[^\s,]*,[\w+/=%.~-]*\s*(?:\*\/)?\s*$/u
|
|
22
|
+
export const SOURCE_MAP = /\.map$/iu
|
|
23
|
+
const MINIFIED_NAME = /\.min\.[^/.]+$/iu
|
|
24
|
+
// What javascript-obfuscator writes, and next to nothing else: its names,
|
|
25
|
+
// `_0x` and four to six hex digits, OBFUSCATED_NAMES of them or more, or the
|
|
26
|
+
// loop it turns its array of strings with, `while(!![])`, spaced or not.
|
|
27
|
+
const OBFUSCATED_NAME = /(?<![\p{ID_Continue}$])_0x[0-9a-f]{4,6}(?![\p{ID_Continue}$])/gu
|
|
28
|
+
export const OBFUSCATED_NAMES = 20
|
|
29
|
+
const OBFUSCATED_LOOP = /while\s*\(\s*!!\[\]\s*\)/u
|
|
30
|
+
// Names counted before a fingerprint's count stops, reading as that many or
|
|
31
|
+
// more: each is kept to tell it from the others, and a file can hold millions.
|
|
32
|
+
export const OBFUSCATED_COUNTED = 1000
|
|
33
|
+
// A text's obfuscator fingerprint, `{ names, loop }`: how many such names it
|
|
34
|
+
// has, up to OBFUSCATED_COUNTED, and whether it has the loop; null where it
|
|
35
|
+
// has neither enough names nor the loop.
|
|
36
|
+
export function npmObfuscation(text) {
|
|
37
|
+
const names = new Set()
|
|
38
|
+
for (const [name] of text.matchAll(OBFUSCATED_NAME)) {
|
|
39
|
+
names.add(name)
|
|
40
|
+
if (names.size === OBFUSCATED_COUNTED) break
|
|
41
|
+
}
|
|
42
|
+
const loop = OBFUSCATED_LOOP.test(text)
|
|
43
|
+
return names.size >= OBFUSCATED_NAMES || loop ? { names: names.size, loop } : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A code file this large is mostly a bundle, worth a look where a version
|
|
47
|
+
// adds it: JavaScript or TypeScript, not a declaration file, which holds no
|
|
48
|
+
// code that runs.
|
|
49
|
+
export const NPM_LARGE_CODE = 256 * 1024
|
|
50
|
+
const CODE = /\.(?:[cm]?[jt]s|[jt]sx)$/iu
|
|
51
|
+
const DECLARATION = /\.d\.[cm]?ts$/iu
|
|
52
|
+
export const npmLargeCode = (path, bytes) => typeof bytes === 'number' && bytes >= NPM_LARGE_CODE && CODE.test(path) && !DECLARATION.test(path)
|
|
53
|
+
|
|
54
|
+
// A file with a line over UNREADABLE_LINE characters, by how it reads
|
|
55
|
+
// (`now`, npmFileReadability's), `path` its path: an inline source map's line
|
|
56
|
+
// counts for none, and prose and source maps, whose long lines the Long lines
|
|
57
|
+
// tag passes over, never are, nor is a file that isn't text. Then one that
|
|
58
|
+
// had no line over SHORT_LINES characters (`was`) and now has one over
|
|
59
|
+
// UNREADABLE_LINE, by how it reads on each side.
|
|
60
|
+
const SHORT_LINES = 200
|
|
61
|
+
const UNREADABLE_LINE = 2000
|
|
62
|
+
export const npmUnreadable = (path, now) => Boolean(now) && now.category !== 'binary' && !NPM_PROSE.test(path) && !SOURCE_MAP.test(path)
|
|
63
|
+
&& now.longest > UNREADABLE_LINE
|
|
64
|
+
export const npmBecameUnreadable = (path, was, now) => Boolean(was) && was.category !== 'binary' && was.longest <= SHORT_LINES && npmUnreadable(path, now)
|
|
65
|
+
|
|
66
|
+
// How a file reads, as its `category`, the first that holds (READABILITY):
|
|
67
|
+
// binary (no text), map (a source map), long (code with some lines
|
|
68
|
+
// longer than anyone writes), inline-map (code with its source map in it, minified or
|
|
69
|
+
// not), minified (code mostly on long lines, or named .min., or minified
|
|
70
|
+
// into shorter lines: see MINIFIED_AVERAGE), else utf8 or ascii; and
|
|
71
|
+
// whatever it is, the control, bidirectional or invisible characters a text
|
|
72
|
+
// holds (`controls`), found in one pass, which count as a label of their own
|
|
73
|
+
// beside it each, control and invisible (npmReadabilityLabels). Prose is
|
|
74
|
+
// readable whatever its lines' lengths, and an inline source map's line
|
|
75
|
+
// counts for none, nor for how much of the code is on long lines. With
|
|
76
|
+
// npmTextEncoding's `kind` and `controls`, its `longest` line's length and
|
|
77
|
+
// `longLines`, its code's lines' `average` length where it is minified into
|
|
78
|
+
// short ones (strings and comments aside, see MINIFIED_AVERAGE; 0 otherwise),
|
|
79
|
+
// how long its inline map is (`inlineMap`, 0 for none), and its obfuscator
|
|
80
|
+
// fingerprint (`obfuscation`, npmObfuscation's; null for none, and for prose
|
|
81
|
+
// or a source map).
|
|
82
|
+
export function npmFileReadability(path, text) {
|
|
83
|
+
const encoding = npmTextEncoding(text)
|
|
84
|
+
if (encoding.kind === 'binary') return { ...encoding, category: 'binary', longest: 0, longLines: 0, average: 0, inlineMap: 0, obfuscation: null }
|
|
85
|
+
let inlineMap = 0, longChars = 0, longLines = 0, longest = 0
|
|
86
|
+
for (let at = 0; at <= text.length;) {
|
|
87
|
+
const next = text.indexOf('\n', at)
|
|
88
|
+
const end = next === -1 ? text.length : next
|
|
89
|
+
const length = end - at - (text[end - 1] === '\r' ? 1 : 0)
|
|
90
|
+
if (SOURCE_MAP_COMMENT.test(text.slice(at, at + 64)) && INLINE_SOURCE_MAP.test(text.slice(at, end))) inlineMap += length
|
|
91
|
+
else {
|
|
92
|
+
if (length > NPM_LONG_LINE) {
|
|
93
|
+
longLines++
|
|
94
|
+
longChars += length
|
|
95
|
+
}
|
|
96
|
+
longest = Math.max(longest, length)
|
|
97
|
+
}
|
|
98
|
+
at = end + 1
|
|
99
|
+
}
|
|
100
|
+
// A source map holds the names and sources of the code it maps, and prose
|
|
101
|
+
// its examples: neither runs.
|
|
102
|
+
const obfuscation = NPM_PROSE.test(path) || SOURCE_MAP.test(path) ? null : npmObfuscation(text)
|
|
103
|
+
const read = { ...encoding, longest, longLines, average: 0, inlineMap, obfuscation }
|
|
104
|
+
if (SOURCE_MAP.test(path)) return { ...read, category: 'map' }
|
|
105
|
+
if (NPM_PROSE.test(path)) return { ...read, category: inlineMap > 0 ? 'inline-map' : encoding.kind }
|
|
106
|
+
if (longLines === 0) {
|
|
107
|
+
// None of its lines longer than MINIFIED_AVERAGE, none of its code's can
|
|
108
|
+
// average more: told without reading its code.
|
|
109
|
+
const language = minifiable(path)
|
|
110
|
+
const average = language !== null && longest > MINIFIED_AVERAGE ? minifiedAverage(text, language) : 0
|
|
111
|
+
return { ...read, average, category: inlineMap > 0 ? 'inline-map' : average > 0 ? 'minified' : encoding.kind }
|
|
112
|
+
}
|
|
113
|
+
if (longChars / (text.length - inlineMap) < .5 && !MINIFIED_NAME.test(path)) return { ...read, category: 'long' }
|
|
114
|
+
return { ...read, category: inlineMap > 0 ? 'inline-map' : 'minified' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The category a file's tag shows: for one with long lines, how its text is
|
|
118
|
+
// encoded.
|
|
119
|
+
export const npmTaggedCategory = readability => readability.category === 'long' ? readability.kind : readability.category
|
|
120
|
+
|
|
121
|
+
// What a file is labelled, in its tags and in the Overview's warning and
|
|
122
|
+
// chips, each finding it: its control characters, its invisible ones
|
|
123
|
+
// (characterLabels) and its long lines, where it holds them, beside its category (how its text is encoded, for one whose long
|
|
124
|
+
// lines are unexpected), but for ASCII or UTF-8, which a file that can't be
|
|
125
|
+
// read as it is counts under as none of them. A minified file's long lines
|
|
126
|
+
// are labelled too, as its inline map's are; prose's and a source map's
|
|
127
|
+
// never are.
|
|
128
|
+
export function npmReadabilityLabels(readability) {
|
|
129
|
+
const flags = [...characterLabels(readability.controls),
|
|
130
|
+
...readability.longLines > 0 && ['long', 'minified', 'inline-map'].includes(readability.category) ? ['long'] : []]
|
|
131
|
+
const category = npmTaggedCategory(readability)
|
|
132
|
+
return READABILITY.get(category).mark || flags.length === 0 ? [...flags, category] : flags
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// How a label is marked on a file: long lines as a warning where it is
|
|
136
|
+
// otherwise readable, as they are unexpected there, and as a notice in
|
|
137
|
+
// minified code, as minified code is; any other as its category is.
|
|
138
|
+
export const npmLabelMark = (readability, label) => label === 'long' && readability.category !== 'long' ? 'notice' : READABILITY.get(label).mark
|
|
139
|
+
|
|
140
|
+
// Each category: its name, its tag in a list of files or a file's head (and,
|
|
141
|
+
// `short`, in a file tree, where that is too long), and how it is marked,
|
|
142
|
+
// `warn` for what to look into, `notice` for what can be hard to review by
|
|
143
|
+
// reading it either way.
|
|
144
|
+
export const READABILITY = new Map([
|
|
145
|
+
['binary', { name: 'Binary', tag: 'Binary', mark: 'warn' }],
|
|
146
|
+
['controls', { name: 'Control chars', tag: 'Controls', mark: 'warn' }],
|
|
147
|
+
['invisible', { name: 'Invisible chars', tag: 'Invisible', mark: 'warn' }],
|
|
148
|
+
['long', { name: 'Long lines', tag: 'Long lines', mark: 'warn' }],
|
|
149
|
+
['minified', { name: 'Minified', tag: 'Minified', short: 'Min', mark: 'notice' }],
|
|
150
|
+
['map', { name: 'Source maps', tag: 'Map', mark: 'notice' }],
|
|
151
|
+
['inline-map', { name: 'Inline source maps', tag: 'Inline map', mark: 'notice' }],
|
|
152
|
+
['utf8', { name: 'UTF-8', tag: 'UTF-8', mark: null }],
|
|
153
|
+
['ascii', { name: 'ASCII', tag: 'ASCII', mark: null }],
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
// The characters a text holds of a label's kind, `controls` (C0, DEL, C1
|
|
157
|
+
// and bidirectional controls) or `invisible`, `[point, times]`, the most
|
|
158
|
+
// found first: those npmTextEncoding found in one pass, told apart by code
|
|
159
|
+
// point. The labels a text's characters take, of those two.
|
|
160
|
+
const CONTROL = new RegExp(CONTROLS.source, 'u')
|
|
161
|
+
export const charactersOf = (controls, label) => [...controls ?? []]
|
|
162
|
+
.filter(([point]) => CONTROL.test(String.fromCodePoint(point)) === (label === 'controls')).toSorted((a, b) => b[1] - a[1] || a[0] - b[0])
|
|
163
|
+
const characterLabels = controls => controls ? ['controls', 'invisible'].filter(label => charactersOf(controls, label).length > 0) : []
|
|
164
|
+
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// What a file's code can do beyond itself, for a reviewer: run a string as
|
|
2
|
+
// code (eval, Function with new or without, node:vm, Module's _compile) or
|
|
3
|
+
// other programs (child_process), reach for credentials (a credential file's
|
|
4
|
+
// path, a token's name) or the whole environment, and publish to npm (`npm
|
|
5
|
+
// publish`, `/-/whoami`, a registry PUT), as a file's stamps say
|
|
6
|
+
// (stamps.js) and a version's findings name what is new to a file.
|
|
7
|
+
// A quick read, not a parse: a script's code with its strings, regular
|
|
8
|
+
// expressions and comments set aside as script.js tells them, its
|
|
9
|
+
// strings read for the paths, names and commands they hold, a module by the
|
|
10
|
+
// string naming it where it is loaded (`require('child_process')`,
|
|
11
|
+
// `import … from 'node:child_process'`); a declaration file (.d.ts) read as
|
|
12
|
+
// TypeScript, as node runs it (stripping its types, `declare function
|
|
13
|
+
// eval(…)` among them, a function named, not called). A call written not to
|
|
14
|
+
// read as one (`window['ev' + 'al']`) goes unseen, as does one in a template
|
|
15
|
+
// literal in another's substitution (`${`${eval(x)}`}`), whose backtick ends
|
|
16
|
+
// the outer one as the regular expression telling strings reads it.
|
|
17
|
+
//
|
|
18
|
+
// Labels to assist a reviewer, making the plain cases less likely to be
|
|
19
|
+
// missed, not a check to rely on: code can come to eval, Function,
|
|
20
|
+
// child_process, credentials or the registry in more ways than any reading of
|
|
21
|
+
// its text tells (a name built as it runs, `(…).constructor('…')`, a module
|
|
22
|
+
// required by a name computed, a path or URL put together, the
|
|
23
|
+
// registry's host read from config), so a file without a label is not one
|
|
24
|
+
// without them.
|
|
25
|
+
import { JS_STRING_OR_COMMENT, NPM_MAX_FOUND, lineOf, npmIsScript } from './script.js'
|
|
26
|
+
|
|
27
|
+
// What may come between a call's parts: blanks and comments
|
|
28
|
+
// (`Function/* note */(…)`), each comment ending at its first `*/`.
|
|
29
|
+
const COMMENT = String.raw`/\*(?:[^*]|\*(?!/)){0,100}\*/|//[^\n]{0,100}\n`
|
|
30
|
+
const GAP = String.raw`(?:\s|${COMMENT})*`
|
|
31
|
+
// Function called: as it is, optionally (`Function?.(…)`), or by its call or
|
|
32
|
+
// apply (`Function.call(null, …)`), each making a function of its last
|
|
33
|
+
// argument.
|
|
34
|
+
const FUNCTION_CALLED = String.raw`(?:${GAP}\??\.${GAP}(?:call|apply))?${GAP}(?:\?\.${GAP})?\(`
|
|
35
|
+
// But not where the function made returns `this` alone
|
|
36
|
+
// (`Function('return this')()`, `new Function("return this;")`): the global
|
|
37
|
+
// object, as older code finds it where `globalThis` may not be, lodash's and
|
|
38
|
+
// core-js's among them, running nothing of its own.
|
|
39
|
+
const GLOBAL_FOUND = String.raw`(?!${GAP}(?<quote>["'\u0060])\s*return\s+this\s*;?\s*\k<quote>${GAP}\))`
|
|
40
|
+
// The environment, whole: not one variable of it (`process.env.HOME`,
|
|
41
|
+
// `process.env[name]`).
|
|
42
|
+
const ENV = String.raw`process${GAP}\.${GAP}env(?![\p{ID_Continue}$])(?!${GAP}(?:\??\.|\[))`
|
|
43
|
+
// Its values read out whole (`JSON.stringify(process.env)`,
|
|
44
|
+
// `Object.entries(process.env)`, `new URLSearchParams(process.env)`), or a
|
|
45
|
+
// copy of them (`JSON.stringify({ ...process.env })`), not as a program is
|
|
46
|
+
// handed it (`{ env: process.env }`, `{ ...process.env }`), nor its
|
|
47
|
+
// names listed (`Object.keys(process.env)`, as debug picks its DEBUG_ ones),
|
|
48
|
+
// nor one variable asked for (`'HOME' in process.env`), nor its entries or
|
|
49
|
+
// values picked among as they are read (`Object.entries(process.env)
|
|
50
|
+
// .reduce(…)`, as npm's agent finds its proxy's, `.filter(…)`, `.find(…)`).
|
|
51
|
+
const PICKED = String.raw`${GAP}\)${GAP}\??\.${GAP}(?:filter|find|findLast|some|every|reduce)(?![\p{ID_Continue}$])`
|
|
52
|
+
const COPY = String.raw`(?:\{${GAP}\.\.\.${GAP})?`
|
|
53
|
+
const ENV_WHOLE = String.raw`(?=JSON|Object|URLSearchParams)(?<![\p{ID_Continue}$.])(?:JSON${GAP}\.${GAP}stringify|URLSearchParams)${GAP}\(${GAP}${COPY}${ENV}`
|
|
54
|
+
+ String.raw`|(?=Object)(?<![\p{ID_Continue}$.])Object${GAP}\.${GAP}(?:values|entries)${GAP}\(${GAP}${COPY}${ENV}(?!${PICKED})`
|
|
55
|
+
// Any of RUNS_CODE, as a text may say it.
|
|
56
|
+
const SAYS_ANY = new RegExp(String.raw`eval|Function${FUNCTION_CALLED}|vm["'\x60]|_compile|child_process|\.(?:npmrc|ssh|aws)|_TOKEN|(?:stringify|values|entries|URLSearchParams)${GAP}\(${GAP}(?:\{${GAP}\.\.\.${GAP})?process${GAP}\.${GAP}env|publish|whoami|registry\.(?:npmjs\.org|yarnpkg\.com)`, 'u')
|
|
57
|
+
|
|
58
|
+
// A name read as one, not as a property's (`vm.eval`, `vm . eval`, a spread's
|
|
59
|
+
// `...eval` aside) or part of another's (`evaluate`), or as the global
|
|
60
|
+
// object's (`window.eval`, `globalThis?.eval`); looked back on only where the
|
|
61
|
+
// name or the global object's starts, so a long run of blanks reads in one go.
|
|
62
|
+
const GLOBAL = 'globalThis|window|global|self'
|
|
63
|
+
const named = name => String.raw`(?=${GLOBAL}|${name})(?<![\p{ID_Continue}$#])(?<!(?<!\.)\.${GAP})(?<!\bfunction${GAP})(?:(?:${GLOBAL})${GAP}\??\.${GAP})?${name}`
|
|
64
|
+
// A token's name, as its value is read (`process.env.GITHUB_TOKEN || null`,
|
|
65
|
+
// `env.NPM_TOKEN`, `||=`), not where its absence is told
|
|
66
|
+
// (`!process.env.GITHUB_TOKEN`, `!(process.env.GITHUB_TOKEN)`) or it is
|
|
67
|
+
// dropped (`delete process.env.NPM_TOKEN`), the token the whole of what is
|
|
68
|
+
// negated or deleted (ABSENT_ENDS: not `!process.env.GITHUB_TOKEN.trim()`,
|
|
69
|
+
// `!(process.env.GITHUB_TOKEN === x)`), nor where it is only set
|
|
70
|
+
// (`process.env.NPM_TOKEN = token`), but for a default as it is destructured
|
|
71
|
+
// (`{ NPM_TOKEN = '' } = process.env`, a line of its own too), nor declared
|
|
72
|
+
// (`const NPM_TOKEN = …`, TypeScript's `let GITHUB_TOKEN: string`), nor typed
|
|
73
|
+
// (a member, `GITHUB_TOKEN?: string`, `NPM_TOKEN: string | undefined`, or a
|
|
74
|
+
// parameter): looked back on, a few names and dots at most, only where the
|
|
75
|
+
// name starts.
|
|
76
|
+
const TOKEN_VAR = '(?:NPM|GITHUB)_TOKEN'
|
|
77
|
+
const SET = String.raw`\s{0,4}=(?![=>])`
|
|
78
|
+
// A name typed, not read: optional (`?:`), or a primitive's type after it,
|
|
79
|
+
// as no destructuring names what it reads (`{ NPM_TOKEN: token }`); not
|
|
80
|
+
// `undefined`, a ternary's choice (`x ? env.NPM_TOKEN : undefined`).
|
|
81
|
+
const TYPED = String.raw`\s{0,4}(?:\?:|:\s{0,4}(?:string|number|boolean|unknown|any)(?![\p{ID_Continue}$]))`
|
|
82
|
+
// What may follow a token negated or deleted whole: the parentheses around
|
|
83
|
+
// it closed, nothing read of what they hold (`!(token).trim()`, `!(token)()`), or else what
|
|
84
|
+
// ends an operand (`&&`, `||`, `??`, a ternary's `?`, `;`, `,`, `:`, `}`, `]`,
|
|
85
|
+
// the line's end).
|
|
86
|
+
const ABSENT_ENDS = String.raw`\s{0,4}(?:(?:\)\s{0,4}){1,4}(?![\s)]{0,8}(?:[.[(]|\?\.))|&&|\|\||\?\?|\?(?!\.)|[;,:}\]]|$)`
|
|
87
|
+
const TOKEN_NAME = String.raw`(?=NPM|GITHUB)(?<![\p{ID_Continue}$])(?<!\b(?:const|let|var)\s{1,4})`
|
|
88
|
+
+ String.raw`(?:(?<!(?:!|\bdelete\s)[\s(]{0,4}(?:[\p{ID_Continue}$]{1,64}\s{0,4}\??\.\s{0,4}){0,4})|(?!${TOKEN_VAR}${ABSENT_ENDS}))`
|
|
89
|
+
+ String.raw`${TOKEN_VAR}(?![\p{ID_Continue}$])(?!${TYPED})(?:(?!${SET})|(?<=[{,]\s{0,64}${TOKEN_VAR}))`
|
|
90
|
+
// A script's strings, regular expressions and comments, as script.js
|
|
91
|
+
// tells them, each taking its own text, and its code in none of them: eval,
|
|
92
|
+
// not a property TypeScript types as optional (`eval?: T`, JavaScript's `?:`
|
|
93
|
+
// none of its own), and Function called, but to find the global object;
|
|
94
|
+
// Module's _compile, called
|
|
95
|
+
// (`module._compile(source, filename)`) or hooked (`Module.prototype._compile
|
|
96
|
+
// = …`), not named in a type (`typeof Module.prototype._compile`); a token's
|
|
97
|
+
// name, its value read; the environment read out whole; and a PUT request's
|
|
98
|
+
// call (`axios.put(…)`).
|
|
99
|
+
const READS = new RegExp(String.raw`${JS_STRING_OR_COMMENT.source}|(?<eval>${named('eval')}(?![\p{ID_Continue}$])(?!\s*\?:))|(?<function>${named('Function')}${FUNCTION_CALLED}${GLOBAL_FOUND})`
|
|
100
|
+
+ String.raw`|(?<compile>\.${GAP}_compile(?:${FUNCTION_CALLED}|${GAP}=(?![=>])))|(?<tokens>${TOKEN_NAME})|(?<env>${ENV_WHOLE})|(?<put>\.${GAP}put${GAP}\()`, 'gmu')
|
|
101
|
+
// What a string holds that a kind is: a credential file's path, npm's, SSH's
|
|
102
|
+
// or AWS's (`~/.npmrc`, `.ssh/id_rsa`, `.aws/credentials`, not `x.npmrc` or
|
|
103
|
+
// `.sshd`); a token's name where it is read, the string the name alone (a
|
|
104
|
+
// key, `process.env['GITHUB_TOKEN']`) or the name expanded (a shell's or
|
|
105
|
+
// .npmrc's `$GITHUB_TOKEN`, `${NPM_TOKEN}`, cmd's `%NPM_TOKEN%`), not where
|
|
106
|
+
// text names it (`requires a token in GITHUB_TOKEN`); a command publishing (`npm publish`, `pnpm
|
|
107
|
+
// publish …`, options before it as npm takes them, `npm --tag next publish`)
|
|
108
|
+
// or the registry's who-am-I, a publisher's first step with a token. And the
|
|
109
|
+
// registry's host, where a PUT request is one publishing.
|
|
110
|
+
// Told first of a string by what any of them says.
|
|
111
|
+
const HOLDS_ANY = /\.(?:npmrc|ssh|aws)|_TOKEN|publish|whoami|registry\./u
|
|
112
|
+
// Before a token's name as a key, its absence told or the token dropped, as
|
|
113
|
+
// TOKEN_NAME tells of one as a name (`!process.env['GITHUB_TOKEN']`,
|
|
114
|
+
// `delete process.env['NPM_TOKEN']`): a few names and dots at most, and
|
|
115
|
+
// after it, its key's bracket closed, what may follow it whole (ABSENT_ENDS,
|
|
116
|
+
// not `!process.env['NPM_TOKEN'].length`). After it, the token set
|
|
117
|
+
// (`process.env['NPM_TOKEN'] = token`).
|
|
118
|
+
const KEY_SET = new RegExp(String.raw`^\s{0,4}\]${SET}`, 'u')
|
|
119
|
+
const KEY_ENDS = new RegExp(String.raw`^\s{0,4}\]${ABSENT_ENDS}`, 'mu')
|
|
120
|
+
const KEY_ABSENT = /(?:!|\bdelete\s)[\s(]{0,4}(?:[\p{ID_Continue}$]{1,64}\s{0,4}\??\.\s{0,4}){0,4}[\p{ID_Continue}$]{1,64}\s{0,4}(?:\?\.\s{0,4})?\[\s{0,4}$/u
|
|
121
|
+
const HOLDS = [
|
|
122
|
+
['credential-files', /(?<![\w$.-])\.(?:npmrc|ssh|aws)(?![\w-])/gu],
|
|
123
|
+
['tokens', new RegExp(String.raw`^["'\u0060]${TOKEN_VAR}["'\u0060]$|(?:\$\{?|%)${TOKEN_VAR}(?![\p{ID_Continue}$])`, 'gu')],
|
|
124
|
+
['publish', /(?<![\w-])(?:npm|pnpm|yarn)(?:\s+-{1,2}\w[\w-]*(?:=\S*|\s+(?!-)\S+)?){0,8}?\s+publish(?![\w-])|\/-\/whoami(?![\w-])/gu],
|
|
125
|
+
['registry', /registry\.(?:npmjs\.org|yarnpkg\.com)/gu],
|
|
126
|
+
]
|
|
127
|
+
// A module a kind loads: vm, child_process, and libnpmpublish, npm's own
|
|
128
|
+
// publishing.
|
|
129
|
+
const MODULES = [
|
|
130
|
+
['vm', /^(["'\u0060])(?:node:)?vm\1$/u],
|
|
131
|
+
['child-process', /^(["'\u0060])(?:node:)?child_process\1$/u],
|
|
132
|
+
['publish', /^(["'\u0060])libnpmpublish\1$/u],
|
|
133
|
+
]
|
|
134
|
+
// A string a program is run with publishing (`spawn('npm', ['publish'])`),
|
|
135
|
+
// after the one naming it, its options and their values between
|
|
136
|
+
// (`['--tag', 'next', 'publish']`), and a request's method set to PUT
|
|
137
|
+
// (`{ method: 'PUT' }`, `options.method = 'put'`).
|
|
138
|
+
const PROGRAM = /^(["'\u0060])(?:npm|pnpm|yarn)\1$/u
|
|
139
|
+
const PUBLISH = /^(["'\u0060])publish\1$/u
|
|
140
|
+
const OPTION = /^(["'\u0060])-/u
|
|
141
|
+
const ARGUMENTS_BETWEEN = new RegExp(String.raw`^(?:[\s,[]|${COMMENT})*$`, 'u')
|
|
142
|
+
const PUT = /^(["'\u0060])put\1$/iu
|
|
143
|
+
const METHOD = new RegExp(String.raw`\bmethod["']?${GAP}[:=]${GAP}$`, 'u')
|
|
144
|
+
// What comes before a module's name where code loads it: `require(` (or
|
|
145
|
+
// `require?.(`), a bundler's `__require(` or `__non_webpack_require__(`,
|
|
146
|
+
// `import(`, `import` and `from`, comments allowed between
|
|
147
|
+
// (`import(/* webpackIgnore: true */ …)`); a name not loaded, as in a list of
|
|
148
|
+
// node's modules, is none, nor one a type-only import or export names
|
|
149
|
+
// (`import type { … } from`, `import type cp = require(`, comments allowed as
|
|
150
|
+
// in a load), which TypeScript erases (`import type from` imports a default
|
|
151
|
+
// named type, `import type = require(` one named so).
|
|
152
|
+
const LOADS = new RegExp(String.raw`(?:(?:require[\w$]*${GAP}(?:\?\.${GAP})?|\bimport${GAP})\(|\b(?:from|import))${GAP}$`, 'u')
|
|
153
|
+
const TYPE_ONLY = new RegExp(String.raw`\b(?:import|export)${GAP}type\b(?!${GAP}(?:from\b|=))[^;'"\x60]*(?:\bfrom|=${GAP}require${GAP}\()${GAP}$`, 'u')
|
|
154
|
+
|
|
155
|
+
// Each kind, in the order its tags show: its tag, what it does, its name in
|
|
156
|
+
// the tag's tooltip, a row of its own where kinds share the tag (a string run
|
|
157
|
+
// as code by any way, `eval`'s; a credential file or token, `credentials`'),
|
|
158
|
+
// and for one marked as worse than the others' warning, `critical`: the whole
|
|
159
|
+
// environment read out, and publishing.
|
|
160
|
+
const AS_CODE = { tag: 'eval', note: 'Runs a string as code' }
|
|
161
|
+
const CREDENTIALS = { tag: 'credentials', note: 'Reaches for credentials' }
|
|
162
|
+
export const RUNS_CODE = new Map([
|
|
163
|
+
['eval', { ...AS_CODE, name: 'eval' }],
|
|
164
|
+
['function', { ...AS_CODE, name: 'Function' }],
|
|
165
|
+
['vm', { ...AS_CODE, name: 'vm' }],
|
|
166
|
+
['compile', { ...AS_CODE, name: 'Module._compile' }],
|
|
167
|
+
['child-process', { tag: 'child_process', note: 'Runs other programs', name: 'child_process' }],
|
|
168
|
+
['credential-files', { ...CREDENTIALS, name: '.npmrc, .ssh or .aws' }],
|
|
169
|
+
['tokens', { ...CREDENTIALS, name: 'NPM_TOKEN or GITHUB_TOKEN' }],
|
|
170
|
+
['env', { tag: 'whole env', note: 'Reads out the whole environment', name: 'process.env, by JSON.stringify, Object.entries or values, URLSearchParams', mark: 'critical' }],
|
|
171
|
+
['publish', { tag: 'publish', note: 'Publishes to npm', name: 'npm publish, /-/whoami or a registry PUT', mark: 'critical' }],
|
|
172
|
+
])
|
|
173
|
+
|
|
174
|
+
// The kinds a file's code holds, each with the lines it is on, of the first
|
|
175
|
+
// NPM_MAX_FOUND places it does each, in RUNS_CODE's order; null for none, or
|
|
176
|
+
// for a file that is no script.
|
|
177
|
+
export function npmRunsCode(path, text) {
|
|
178
|
+
if (!npmIsScript(path, text)) return null
|
|
179
|
+
const found = new Map()
|
|
180
|
+
const at = (kind, offset) => {
|
|
181
|
+
const offsets = found.get(kind)
|
|
182
|
+
if (!offsets) found.set(kind, [offset])
|
|
183
|
+
else if (offsets.length < NPM_MAX_FOUND) offsets.push(offset)
|
|
184
|
+
}
|
|
185
|
+
read(text, 0, at)
|
|
186
|
+
// A PUT request publishing where the file names the registry.
|
|
187
|
+
if (found.has('registry')) for (const offset of found.get('put') ?? []) at('publish', offset)
|
|
188
|
+
if (![...RUNS_CODE.keys()].some(kind => found.has(kind))) return null
|
|
189
|
+
const line = lineOf(text)
|
|
190
|
+
return new Map([...RUNS_CODE.keys()].filter(kind => found.has(kind)).map(kind => [kind, [...new Set(found.get(kind).map(line))].toSorted((a, b) => a - b)]))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Where code (a file's, or a template literal's substitution's, at `base` in
|
|
194
|
+
// the file) does a kind, as at(kind, offset), in order: its code's, what its
|
|
195
|
+
// strings hold, a string naming a module where it is loaded, and what a
|
|
196
|
+
// template literal's substitutions (`${eval(x)}`) do, code again, read as its
|
|
197
|
+
// own; a PUT request (`put`) and the registry's host (`registry`) as their
|
|
198
|
+
// own, for the file to pair. Most code says none of it at all, told before
|
|
199
|
+
// anything is read.
|
|
200
|
+
function read(code, base, at) {
|
|
201
|
+
if (!SAYS_ANY.test(code)) return
|
|
202
|
+
// Where the string before ends; whether the strings since a program's name
|
|
203
|
+
// are its arguments, an option or its value each, and whether the last is
|
|
204
|
+
// an option.
|
|
205
|
+
let end = 0, option = false, program = false
|
|
206
|
+
for (const { 0: token, 1: string, groups, index } of code.matchAll(READS)) {
|
|
207
|
+
if (string === undefined) {
|
|
208
|
+
for (const kind of CODE_KINDS) if (groups[kind]) at(kind, base + index)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (string[0] === '/') continue
|
|
212
|
+
const inner = string[0] === '`' && SAYS_ANY.test(string) ? substitutions(string) : []
|
|
213
|
+
for (const [from, to] of inner) read(string.slice(from, to), base + index + from, at)
|
|
214
|
+
// What a string holds, a template's text alone, its substitutions' code
|
|
215
|
+
// read as code, its comments and regular expressions aside.
|
|
216
|
+
const text = inner.length === 0 ? string : textOf(string, inner)
|
|
217
|
+
// A token's name as the string's whole (`within` 0, its quote) is a key,
|
|
218
|
+
// not one where its absence is told, it is dropped or only set.
|
|
219
|
+
const keyed = () => {
|
|
220
|
+
const after = code.slice(index + token.length, index + token.length + 20)
|
|
221
|
+
return !KEY_SET.test(after) && (!KEY_ENDS.test(after) || !KEY_ABSENT.test(code.slice(Math.max(0, index - 300), index)))
|
|
222
|
+
}
|
|
223
|
+
const held = HOLDS_ANY.test(text) ? HOLDS.flatMap(([kind, holds]) => [...text.matchAll(holds)].map(({ index: within }) => [kind, within])) : []
|
|
224
|
+
for (const [kind, within] of held) if (kind !== 'tokens' || within > 0 || keyed()) at(kind, base + index + within)
|
|
225
|
+
for (const [kind, name] of MODULES) if (name.test(string) && loaded(code, index)) at(kind, base + index)
|
|
226
|
+
if (program && ARGUMENTS_BETWEEN.test(code.slice(end, index))) {
|
|
227
|
+
if (PUBLISH.test(string)) at('publish', base + index)
|
|
228
|
+
program = OPTION.test(string) || option
|
|
229
|
+
option = OPTION.test(string)
|
|
230
|
+
} else program = false
|
|
231
|
+
if (PROGRAM.test(string)) [program, option] = [true, false]
|
|
232
|
+
if (PUT.test(string) && METHOD.test(code.slice(Math.max(0, index - 300), index))) at('put', base + index)
|
|
233
|
+
end = index + token.length
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The kinds code itself does, by READS' groups.
|
|
238
|
+
const CODE_KINDS = ['eval', 'function', 'compile', 'tokens', 'env', 'put']
|
|
239
|
+
|
|
240
|
+
// Whether the string at `index` in code names a module where it is loaded.
|
|
241
|
+
function loaded(code, index) {
|
|
242
|
+
const before = code.slice(Math.max(0, index - 300), index)
|
|
243
|
+
return LOADS.test(before) && !TYPE_ONLY.test(before)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A template literal with its substitutions' code blanked, its text in place.
|
|
247
|
+
function textOf(template, inner) {
|
|
248
|
+
const parts = []
|
|
249
|
+
let from = 0
|
|
250
|
+
for (const [start, end] of inner) {
|
|
251
|
+
parts.push(template.slice(from, start), ' '.repeat(end - start))
|
|
252
|
+
from = end
|
|
253
|
+
}
|
|
254
|
+
parts.push(template.slice(from))
|
|
255
|
+
return parts.join('')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Where each of a template literal's substitutions' code starts and ends, in
|
|
259
|
+
// it: from its `${` to the `}` that closes it.
|
|
260
|
+
function substitutions(template) {
|
|
261
|
+
const found = []
|
|
262
|
+
for (let i = template.indexOf('${'); i !== -1; i = template.indexOf('${', i)) {
|
|
263
|
+
// Escaped by an odd run of backslashes before it (`\${`, not `\\${`).
|
|
264
|
+
let run = 0
|
|
265
|
+
while (template[i - 1 - run] === '\\') run++
|
|
266
|
+
if (run % 2 === 1) {
|
|
267
|
+
i += 2
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
const end = closing(template, i + 2)
|
|
271
|
+
found.push([i + 2, end])
|
|
272
|
+
i = end
|
|
273
|
+
}
|
|
274
|
+
return found
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A string, regular expression or comment where it starts, told as
|
|
278
|
+
// JS_STRING_OR_COMMENT tells them in a whole text.
|
|
279
|
+
const TOKEN = new RegExp(JS_STRING_OR_COMMENT.source, 'muy')
|
|
280
|
+
|
|
281
|
+
// Where the `}` closing code from `from` in a text is, or the text's end for
|
|
282
|
+
// none: the first its braces leave unbalanced, those in its strings, regular
|
|
283
|
+
// expressions and comments aside (`${/* } */ x}`, `${/}/.test(x)}`).
|
|
284
|
+
function closing(text, from) {
|
|
285
|
+
let depth = 0
|
|
286
|
+
for (let at = from; at < text.length; at++) {
|
|
287
|
+
const char = text[at]
|
|
288
|
+
// Tried where a token can start: a quote, a slash, or a run of blanks, a
|
|
289
|
+
// comment's leading blanks taken from where they start.
|
|
290
|
+
if ('"\'`/ \t'.includes(char)) {
|
|
291
|
+
TOKEN.lastIndex = at
|
|
292
|
+
const token = TOKEN.exec(text)
|
|
293
|
+
if (token) {
|
|
294
|
+
at += token[0].length - 1
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (char === '{') depth++
|
|
299
|
+
else if (char === '}' && depth-- === 0) return at
|
|
300
|
+
}
|
|
301
|
+
return text.length
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// What `found` holds (npmRunsCode's kinds, hosts.js's services) that
|
|
305
|
+
// `was` didn't, each with its lines: what is new to a file, as its tags say,
|
|
306
|
+
// `was` null for one the version compared with didn't hold, or held none in.
|
|
307
|
+
// Null for none.
|
|
308
|
+
export function npmFoundAdded(found, was) {
|
|
309
|
+
if (!found) return null
|
|
310
|
+
const added = new Map([...found].filter(([kind]) => !was?.has(kind)))
|
|
311
|
+
return added.size > 0 ? added : null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The kinds any of `founds` holds, as their tags name them, in their order,
|
|
315
|
+
// a tag kinds share once: `eval, child_process`.
|
|
316
|
+
export const npmRunsCodeKinds = founds => [...new Set([...RUNS_CODE].filter(([kind]) => founds.some(found => found.has(kind))).map(([, { tag }]) => tag))].join(', ')
|
|
317
|
+
|
|
318
|
+
// What `found` holds (npmRunsCode's) but the kinds whose tags `tags` names,
|
|
319
|
+
// each with its lines; null for none.
|
|
320
|
+
export function npmRunsCodeWithout(found, tags) {
|
|
321
|
+
const kept = new Map([...found ?? []].filter(([kind]) => !tags.has(RUNS_CODE.get(kind).tag)))
|
|
322
|
+
return kept.size > 0 ? kept : null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Its tags as a file's flags (file-flags.js), in their order, each once: a
|
|
326
|
+
// warning's (`high`), or for one marked worse, `critical`; none for none.
|
|
327
|
+
export const npmRunsCodeFlags = found => [...new Map([...found?.keys() ?? []].map(kind => RUNS_CODE.get(kind))
|
|
328
|
+
.map(({ tag, mark }) => [tag, { name: tag, severity: mark ?? 'high' }])).values()]
|
|
329
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// How a script's text is read, for risk's readers of a file
|
|
2
|
+
// (readability.js, runs-code.js, hosts.js): JavaScript's and CSS's strings,
|
|
3
|
+
// regular expressions and comments, set apart from their code, what of their
|
|
4
|
+
// blanks a minifier drops, which files are scripts node runs, and each
|
|
5
|
+
// offset's line.
|
|
6
|
+
|
|
7
|
+
// Code minified into lines shorter than NPM_LONG_LINE: outside its strings and
|
|
8
|
+
// comments (`/* @__PURE__ */`), its lines average more than anyone writes and
|
|
9
|
+
// next to none of its spaces are ones a minifier drops. Told only in what
|
|
10
|
+
// minifiers write, as GitHub Linguist tells minified files only in JavaScript
|
|
11
|
+
// and CSS, each by its strings and comments (matched in one pass, so that
|
|
12
|
+
// neither starts inside the other, a comment with the spaces around it) and
|
|
13
|
+
// the spaces it can do without.
|
|
14
|
+
export const MINIFIED_AVERAGE = 110
|
|
15
|
+
const MINIFIED_SPACES = .01
|
|
16
|
+
// What a regular expression set aside holds.
|
|
17
|
+
const REGEX = '\uE000'
|
|
18
|
+
const JS = {
|
|
19
|
+
// A hashbang, the file's first line (`#!/usr/bin/env node`), a comment.
|
|
20
|
+
// A string's or template's escape may be a line continuation (`\` before a
|
|
21
|
+
// line break, CRLF's whole).
|
|
22
|
+
// Linear on any text: a regular expression's context looked back on only
|
|
23
|
+
// before a `/`, a comment's leading blanks taken only from where they
|
|
24
|
+
// start, and what is left open read to where it would end, a string or a
|
|
25
|
+
// regular expression its line's end, a template or a comment the text's,
|
|
26
|
+
// rather than tried again from each later quote or slash.
|
|
27
|
+
// A regular expression first where a value starts (after `=>`, `...`, an operator or a keyword, not a property: `x.default/2`, `this.#in/2`), so a quote in it (`/["']/`) starts no string.
|
|
28
|
+
stringOrComment: /((?=\/)(?<=(?:^|\.\.\.|[(,=:[!&|?{};>+\-*/%^<~]|(?<![\p{ID_Continue}$.#])(?:await|case|default|delete|do|else|extends|in|instanceof|new|of|return|throw|typeof|void|yield))[ \t]*)\/(?![/*])(?:[^/\\[\n]|\\.|\[(?:[^\]\\\n]|\\.)*(?:\]|(?=\\?$)))+(?:\/[a-z]*|(?=\\?$))|"(?:[^"\\\n]|\\(?:\r\n|[\s\S]))*(?:"|$)|'(?:[^'\\\n]|\\(?:\r\n|[\s\S]))*(?:'|$)|`(?:[^`\\]|\\(?:\r\n|[\s\S]))*(?:`|(?![\s\S])))|(?<![ \t])[ \t]*(?:\/\*[\s\S]*?(?:\*\/|(?![\s\S]))|\/\/.*|(?<![\s\S])#!.*)[ \t]*/gmu,
|
|
29
|
+
// Beside punctuation (`a, b`, `x = 1`), not between two words, as
|
|
30
|
+
// JavaScript tells a word's characters (`return a`, `var π`, `var \u03c0`),
|
|
31
|
+
droppable: /(?<![\p{ID_Continue}$\\\u200C\u200D])[ \t]+|[ \t]+(?![\p{ID_Continue}$\\\u200C\u200D])/gu,
|
|
32
|
+
// nor between two `+`, two `-` or two `/` (`a+ +b` is no `a++b`, `a/ /b/`
|
|
33
|
+
// no comment), nor after a regular expression before a word (`/a/ in b`).
|
|
34
|
+
needed: (code, index, run) => ('+-/'.includes(code[index - 1]) && code[index + run.length] === code[index - 1])
|
|
35
|
+
|| (code[index - 2] === REGEX && /[\p{ID_Continue}$\\\u200C\u200D]/u.test(code[index + run.length])),
|
|
36
|
+
}
|
|
37
|
+
const CSS = {
|
|
38
|
+
// No `//` comments, so `url(https://…)` is code.
|
|
39
|
+
stringOrComment: /("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')|[ \t]*\/\*[\s\S]*?\*\/[ \t]*/gu,
|
|
40
|
+
// Beside braces, `;`, `,`, `>`, parentheses, after `:` (`a { color: red }`)
|
|
41
|
+
// and beside a selector's `+` or `~` (`.a + .b`), not those a selector or a
|
|
42
|
+
// value needs (`.a .b`, `1px solid #fff`, `a :hover`),
|
|
43
|
+
droppable: /(?<=[{};,:>(])[ \t]+|[ \t]+(?=[{};,>)!])|(?<=[+~])[ \t]+|[ \t]+(?=[+~])/gu,
|
|
44
|
+
// nor beside `+` or `~` in parentheses (`calc(1px + var(--x))`) or in a
|
|
45
|
+
// custom property's value, kept for a `calc()` to come (`--gap:1px + 2px`).
|
|
46
|
+
needed: (code, index, run, depth) => /[+~]/u.test(code[index - 1] + code[index + run.length])
|
|
47
|
+
&& (depth > 0 || /^\s*--[^:]*:/u.test(code.slice(Math.max(...['{', ';', '}'].map(end => code.lastIndexOf(end, index))) + 1, index))),
|
|
48
|
+
}
|
|
49
|
+
// JavaScript's strings and regular expressions (the first group) and its
|
|
50
|
+
// comments, for a reader that sets them aside (runs-code.js, hosts.js).
|
|
51
|
+
export const JS_STRING_OR_COMMENT = JS.stringOrComment
|
|
52
|
+
export const minifiable = path => /\.[cm]?js$/iu.test(path) ? JS : /\.css$/iu.test(path) ? CSS : null
|
|
53
|
+
// How much of a text is read for how its lines are minified: their average
|
|
54
|
+
// is told by as much of them, and a text of millions of lines is read no
|
|
55
|
+
// further.
|
|
56
|
+
const MINIFIED_READ = 4 * 1024 * 1024
|
|
57
|
+
// Its code's lines' average length where they are minified into short ones,
|
|
58
|
+
// else 0, told by its first MINIFIED_READ characters.
|
|
59
|
+
export function minifiedAverage(whole, { stringOrComment, droppable, needed }) {
|
|
60
|
+
const text = whole.length > MINIFIED_READ ? whole.slice(0, whole.lastIndexOf('\n', MINIFIED_READ) + 1 || MINIFIED_READ) : whole
|
|
61
|
+
// Its lines of code alone, as long as what is on them: a comment set aside
|
|
62
|
+
// leaves nothing but its line breaks, a string or template `""` and its, a
|
|
63
|
+
// regular expression `/REGEX/`, so the code either side stays apart.
|
|
64
|
+
const lines = text.replaceAll(stringOrComment, (match, string) => (string === undefined ? '' : string[0] === '/' ? `/${REGEX}/` : '""') + match.replaceAll(/[^\n]/gu, ''))
|
|
65
|
+
.replaceAll(/^[ \t]+/gmu, '')
|
|
66
|
+
.split('\n').filter(line => line.trim() !== '')
|
|
67
|
+
const code = lines.join('\n'), length = code.length - (lines.length - 1)
|
|
68
|
+
// Counted by character: a run aligning `=` is as many spaces as it is wide.
|
|
69
|
+
let depth = 0, dropped = 0, scanned = 0
|
|
70
|
+
for (const { 0: run, index } of code.matchAll(droppable)) {
|
|
71
|
+
for (; scanned < index; scanned++) depth = Math.max(0, depth + (code[scanned] === '(' ? 1 : code[scanned] === ')' ? -1 : 0))
|
|
72
|
+
if (!needed(code, index, run, depth)) dropped += run.length
|
|
73
|
+
}
|
|
74
|
+
return lines.length > 0 && length > MINIFIED_AVERAGE * lines.length && dropped < MINIFIED_SPACES * length ? Math.round(length / lines.length) : 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// JavaScript or TypeScript (a declaration file too, which node runs as any
|
|
78
|
+
// other), or a script run by node by its hashbang (`bin/cli`, with no
|
|
79
|
+
// extension).
|
|
80
|
+
const SCRIPT = /\.[cm]?[jt]sx?$/iu
|
|
81
|
+
const NODE_HASHBANG = /^#!.*\bnode\b/u
|
|
82
|
+
// Whether a file (its path and text) is a script, as SCRIPT and NODE_HASHBANG tell.
|
|
83
|
+
export const npmIsScript = (path, text) => typeof text === 'string' && (SCRIPT.test(path) || NODE_HASHBANG.test(text))
|
|
84
|
+
// How many places a file is told doing a thing, the first ones, at most
|
|
85
|
+
// (runs-code.js, hosts.js): a file doing it on millions of lines is told by
|
|
86
|
+
// its first, and what it holds of them kept to a bound however many there
|
|
87
|
+
// are.
|
|
88
|
+
export const NPM_MAX_FOUND = 100
|
|
89
|
+
// Every how many line breaks one is kept where lineOf counts from.
|
|
90
|
+
const LINE_MARK = 1024
|
|
91
|
+
// Each offset in a text as its line: one more than the line breaks before it,
|
|
92
|
+
// counted on from the nearest of every LINE_MARKth, which alone are kept.
|
|
93
|
+
export function lineOf(text) {
|
|
94
|
+
let marks = null
|
|
95
|
+
return offset => {
|
|
96
|
+
if (!marks) {
|
|
97
|
+
marks = []
|
|
98
|
+
for (let at = text.indexOf('\n'), count = 0; at !== -1; at = text.indexOf('\n', at + 1), count++) if (count % LINE_MARK === 0) marks.push(at)
|
|
99
|
+
}
|
|
100
|
+
let high = marks.length, low = 0
|
|
101
|
+
while (low < high) {
|
|
102
|
+
const middle = (low + high) >> 1
|
|
103
|
+
if (marks[middle] < offset) low = middle + 1
|
|
104
|
+
else high = middle
|
|
105
|
+
}
|
|
106
|
+
if (low === 0) return 1
|
|
107
|
+
let breaks = (low - 1) * LINE_MARK + 1
|
|
108
|
+
for (let at = text.indexOf('\n', marks[low - 1] + 1); at !== -1 && at < offset; at = text.indexOf('\n', at + 1)) breaks++
|
|
109
|
+
return breaks + 1
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|