@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.46
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/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +25 -2
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +88 -7
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +3 -0
- package/common/workspace-app.js +6 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4UFAMJAX.js +1 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7XRR4OFU.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-BOIXMUU5.js +3 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-CC4OM6TZ.js +3 -0
- package/out/chunk-G24A42V6.js +2 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -1
- package/out/chunk-JKGQ3O7O.js +6 -0
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-S6GIXWQW.js +52 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +30 -34
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +53 -6
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +71 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +1 -1
- package/risk/npm/compare.js +34 -10
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +72 -0
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +66 -19
- package/server-managed/db-neon.ts +89 -21
- package/server-managed/db-schema.ts +13 -2
- package/server-managed/db.ts +9 -1
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +78 -18
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +33 -13
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +1251 -97
- package/server-managed/index.ts +13 -8
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/locked-packages.ts +37 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +178 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +83 -6
- package/server-managed/npm-loads.ts +114 -37
- package/server-managed/npm-packages.ts +110 -64
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +300 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +16 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +166 -37
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +157 -0
- package/server-managed/repository-history.ts +461 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +439 -0
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +133 -118
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +27 -9
- package/server-managed/sql.ts +6 -3
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-app.ts +19 -3
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +25 -5
- package/out/chunk-2ABLKCOY.js +0 -46
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-5LSXOLUS.js +0 -1
- package/out/chunk-5RCM2J63.js +0 -1
- package/out/chunk-7B6HB4D7.js +0 -1
- package/out/chunk-AJ4GTATZ.js +0 -3
- package/out/chunk-CH4UN6WK.js +0 -225
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-EDKLBQQN.js +0 -17
- package/out/chunk-F47DXCEV.js +0 -33
- package/out/chunk-FK3DZEKL.js +0 -1
- package/out/chunk-G6AJWENO.js +0 -1
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-GZWM7UQM.js +0 -2
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LPCTJYNA.js +0 -7
- package/out/chunk-O4RJY5TP.js +0 -2
- package/out/chunk-OD4LPN3H.js +0 -1
- package/out/chunk-QWDWTBM7.js +0 -1
- package/out/chunk-QYWKC557.js +0 -1
- package/out/chunk-R75XOKUP.js +0 -1
- package/out/chunk-SWTLADW5.js +0 -1
- package/out/chunk-T7OKH4VW.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UTR2LVME.js +0 -2
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-ZESLQCTF.js +0 -6
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// What a `uses:` names, as GitHub reads it (`owner/repo[/path]@ref`, `./path`,
|
|
2
|
+
// `$/path`, `docker://image`), and an image a job runs in.
|
|
3
|
+
|
|
4
|
+
const GITHUB_OWNERS = new Set(['actions', 'github'])
|
|
5
|
+
|
|
6
|
+
const OWNER = /^[\da-z](?:[\da-z]|-(?=[\da-z])){0,38}$/iu
|
|
7
|
+
const NAME = /^(?!\.{1,2}$)[\w.-]{1,100}$/u
|
|
8
|
+
const SHA = /^[\da-f]{40}$/u
|
|
9
|
+
const SHORT_SHA = /^[\da-f]{7,39}$/u
|
|
10
|
+
// A version a comment beside a pin names, as Dependabot, Renovate, pinact
|
|
11
|
+
// and ratchet write one (`# v4.1.1`, `# tag=v4.1.1`, `# ratchet:x@v4`).
|
|
12
|
+
const VERSION_COMMENT = /^#\s*(?:tag\s*[=:]\s*|pin@|ratchet:[^\s@]+@)?(v?\d+(?:\.\d+){0,3}(?:[-+][\w.-]+)?)(?![\w.])/iu
|
|
13
|
+
|
|
14
|
+
// `sha`, `short-sha`, `ref` (a tag or a branch), or `expression`, which GitHub refuses.
|
|
15
|
+
function pinOf(ref) {
|
|
16
|
+
if (/\$\{\{/u.test(ref)) return 'expression'
|
|
17
|
+
if (SHA.test(ref)) return 'sha'
|
|
18
|
+
return SHORT_SHA.test(ref) ? 'short-sha' : 'ref'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// An image's name, tag and digest (`[host[:port]/]path[:tag][@digest]`), null
|
|
22
|
+
// for none; null where it isn't one.
|
|
23
|
+
export function readImage(text) {
|
|
24
|
+
if (typeof text !== 'string' || text === '' || /\$\{\{|\s/u.test(text)) return null
|
|
25
|
+
const at = text.indexOf('@')
|
|
26
|
+
const digest = at === -1 ? null : text.slice(at + 1)
|
|
27
|
+
const named = at === -1 ? text : text.slice(0, at)
|
|
28
|
+
const colon = named.lastIndexOf(':')
|
|
29
|
+
const tagged = colon > named.lastIndexOf('/')
|
|
30
|
+
if (digest !== null && !/^sha256:[\da-f]{64}$/u.test(digest)) return null
|
|
31
|
+
return { image: tagged ? named.slice(0, colon) : named, tag: tagged ? named.slice(colon + 1) : null, digest }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const commentVersion = comment => typeof comment === 'string' ? VERSION_COMMENT.exec(comment.trim())?.[1] ?? null : null
|
|
35
|
+
|
|
36
|
+
export const isWorkflowPath = path => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(path)
|
|
37
|
+
|
|
38
|
+
// `{ kind, uses, … }`: `action`, or `workflow` at a job's level (`job`), with
|
|
39
|
+
// its `repo`, `path`, `ref`, `pin` and `comment`'s version; `local`, `self`
|
|
40
|
+
// at the commit running, `job` a reusable workflow's, at the calling
|
|
41
|
+
// workflow's commit; `docker`; or `invalid`, which GitHub won't run.
|
|
42
|
+
export function readUses(text, { job = false, comment = null } = {}) {
|
|
43
|
+
const uses = typeof text === 'string' ? text.trim() : ''
|
|
44
|
+
if (uses.startsWith('./') || uses.startsWith('$/')) {
|
|
45
|
+
return { kind: 'local', uses, path: uses.slice(2).replace(/\/+$/u, ''), ...uses.startsWith('$/') ? { self: true } : {}, ...job ? { job: true } : {} }
|
|
46
|
+
}
|
|
47
|
+
if (uses.startsWith('docker://')) {
|
|
48
|
+
const image = job ? null : readImage(uses.slice('docker://'.length))
|
|
49
|
+
return image ? { kind: 'docker', uses, ...image } : { kind: 'invalid', uses }
|
|
50
|
+
}
|
|
51
|
+
const at = uses.indexOf('@')
|
|
52
|
+
const [owner = '', name = '', ...path] = (at === -1 ? uses : uses.slice(0, at)).split('/')
|
|
53
|
+
const ref = at === -1 ? '' : uses.slice(at + 1)
|
|
54
|
+
if (!OWNER.test(owner) || !NAME.test(name) || ref === '' || path.some(part => !NAME.test(part)) || (job && !isWorkflowPath(path.join('/')))) {
|
|
55
|
+
return { kind: 'invalid', uses }
|
|
56
|
+
}
|
|
57
|
+
return { kind: job ? 'workflow' : 'action', uses, repo: `${owner}/${name}`, path: path.join('/'), ref, pin: pinOf(ref), comment: commentVersion(comment) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The key a reference is resolved once by, a commit's with its comment's version.
|
|
61
|
+
export const usesKey = ({ repo, path, ref, pin, comment }) => `${repo.toLowerCase()}${path ? `/${path}` : ''}@${ref}${pin === 'sha' && comment ? ` # ${comment}` : ''}`
|
|
62
|
+
|
|
63
|
+
// A reference from its key, a reusable workflow's by its path's.
|
|
64
|
+
export function usesOfKey(key) {
|
|
65
|
+
const m = typeof key === 'string' ? /^([^@\s]+@\S+)(?: # (\S+))?$/u.exec(key) : null
|
|
66
|
+
const job = Boolean(m && /^[^/]+\/[^/]+\/\.github\/workflows\/[^/@]+\.ya?ml@/u.test(m[1]))
|
|
67
|
+
const read = m ? readUses(m[1], { job, comment: m[2] ? `# ${m[2]}` : null }) : null
|
|
68
|
+
return read && (read.kind === 'action' || read.kind === 'workflow') && usesKey(read) === key ? read : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// GitHub's own, the workflow's owner's, or another's.
|
|
72
|
+
export function ownerKind(repo, owner) {
|
|
73
|
+
const of = repo.split('/')[0].toLowerCase()
|
|
74
|
+
if (GITHUB_OWNERS.has(of)) return 'github'
|
|
75
|
+
return typeof owner === 'string' && of === owner.toLowerCase() ? 'own' : 'third-party'
|
|
76
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A workflow, or an action's metadata, read for what it runs: its events, its
|
|
2
|
+
// jobs' steps (`uses`, `run`) and images, each on its line from 1.
|
|
3
|
+
import { YamlError, parseYaml } from '@preventive/lockfile/yaml.js'
|
|
4
|
+
import { readImage, readUses } from './uses.js'
|
|
5
|
+
|
|
6
|
+
const isMap = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
7
|
+
const stringOf = value => typeof value === 'string' ? value : null
|
|
8
|
+
|
|
9
|
+
// The line, from 1, `key` of a mapping or an entry of a sequence is on.
|
|
10
|
+
function lineOf(file, container, key, fallback) {
|
|
11
|
+
const at = container !== null && typeof container === 'object' ? file.positions.get(container) : undefined
|
|
12
|
+
const line = at instanceof Map ? at.get(key) : at?.[key]
|
|
13
|
+
return line === undefined ? fallback : line + 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A step's or a job's `uses`, on its line, with the comment that line ends with.
|
|
17
|
+
function usesOf(file, owner, fallback, job = false) {
|
|
18
|
+
if (typeof owner.uses !== 'string') return null
|
|
19
|
+
const line = lineOf(file, owner, 'uses', fallback)
|
|
20
|
+
return { ...readUses(owner.uses, { job, comment: /(?:^|[ \t])(#.*)$/u.exec(file.lines[line - 1] ?? '')?.[1] ?? null }), line }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function triggersOf(on) {
|
|
24
|
+
if (typeof on === 'string') return [on]
|
|
25
|
+
if (Array.isArray(on)) return on.filter(event => typeof event === 'string')
|
|
26
|
+
return isMap(on) ? Object.keys(on) : []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The line a `|` block scalar's text starts on, after its key's; else null.
|
|
30
|
+
const LITERAL_HEADER = /:[ \t]+(?:&\S+[ \t]+)?\|[1-9+-]{0,2}[ \t]*(?:#.*)?$/u
|
|
31
|
+
const literalStart = (file, line) => line !== null && LITERAL_HEADER.test(file.lines[line - 1] ?? '') ? line + 1 : null
|
|
32
|
+
|
|
33
|
+
// A `run:` step's script, and its directory, else `directory` (defaults.run);
|
|
34
|
+
// `production` where its `env`, else the job's or the workflow's (`env`),
|
|
35
|
+
// sets NODE_ENV so, as an install then leaves devDependencies out.
|
|
36
|
+
const envOf = (owner, outer = {}) => ({ ...outer, ...isMap(owner?.env) ? owner.env : {} })
|
|
37
|
+
function runOf(file, step, directory, env) {
|
|
38
|
+
const script = stringOf(step.run)
|
|
39
|
+
if (script === null) return null
|
|
40
|
+
const line = lineOf(file, step, 'run', null)
|
|
41
|
+
return { script, line, start: literalStart(file, line), workingDirectory: stringOf(step['working-directory']) ?? directory,
|
|
42
|
+
production: envOf(step, env).NODE_ENV === 'production' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A step's or a job's `with`, and the line each input is on.
|
|
46
|
+
function inputsOf(file, owner, line) {
|
|
47
|
+
const inputs = isMap(owner.with) ? owner.with : null
|
|
48
|
+
return { with: inputs, withLines: inputs ? Object.fromEntries(Object.keys(inputs).map(key => [key, lineOf(file, inputs, key, line)])) : {} }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stepsOf(file, steps, directory, env = {}) {
|
|
52
|
+
if (!Array.isArray(steps)) return []
|
|
53
|
+
return steps.flatMap((step, index) => {
|
|
54
|
+
if (!isMap(step)) return []
|
|
55
|
+
const line = lineOf(file, steps, index, null)
|
|
56
|
+
return [{ line, uses: usesOf(file, step, line), run: runOf(file, step, directory, env), ...inputsOf(file, step, line) }]
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The directory a workflow's or a job's `defaults.run` names, else `outer`.
|
|
61
|
+
const directoryOf = (owner, outer) => stringOf(isMap(owner?.defaults) && isMap(owner.defaults.run) ? owner.defaults.run['working-directory'] : null) ?? outer
|
|
62
|
+
|
|
63
|
+
// The images a job runs in: its container's and its services'.
|
|
64
|
+
function imagesOf(file, job) {
|
|
65
|
+
const images = []
|
|
66
|
+
const container = isMap(job.container) ? job.container.image : job.container
|
|
67
|
+
if (typeof container === 'string') images.push({ text: container, line: lineOf(file, isMap(job.container) ? job.container : job, isMap(job.container) ? 'image' : 'container', null) })
|
|
68
|
+
if (isMap(job.services)) {
|
|
69
|
+
for (const service of Object.values(job.services)) {
|
|
70
|
+
if (isMap(service) && typeof service.image === 'string') images.push({ text: service.image, line: lineOf(file, service, 'image', null) })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return images.map(image => ({ ...image, read: readImage(image.text) }))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function jobsOf(file, doc) {
|
|
77
|
+
if (!isMap(doc.jobs)) throw new YamlError('a workflow has no jobs')
|
|
78
|
+
const directory = directoryOf(doc, null)
|
|
79
|
+
return Object.values(doc.jobs).filter(isMap).map(job => ({ uses: usesOf(file, job, null, true), secrets: job.secrets ?? null, ...inputsOf(file, job, null),
|
|
80
|
+
steps: stepsOf(file, job.steps, directoryOf(job, directory), envOf(job, envOf(doc))), images: imagesOf(file, job) }))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// An action's `runs`: its runtime, a composite one's steps as a job, and a
|
|
84
|
+
// Docker one's image or Dockerfile.
|
|
85
|
+
function runsOf(file, doc) {
|
|
86
|
+
const runs = isMap(doc.runs) ? doc.runs : null
|
|
87
|
+
if (!runs || typeof runs.using !== 'string') throw new YamlError('an action has no runs.using')
|
|
88
|
+
const using = runs.using.toLowerCase()
|
|
89
|
+
const usingLine = lineOf(file, runs, 'using', null)
|
|
90
|
+
const image = using === 'docker' ? stringOf(runs.image) : null
|
|
91
|
+
const imageLine = lineOf(file, runs, 'image', usingLine)
|
|
92
|
+
const docker = image?.startsWith('docker://') ? { text: image.slice('docker://'.length), line: imageLine, read: readImage(image.slice('docker://'.length)) } : null
|
|
93
|
+
const steps = using === 'composite' ? stepsOf(file, runs.steps, null) : []
|
|
94
|
+
return { using, usingLine, dockerfile: image && !docker ? { path: image.replace(/^\.\//u, ''), line: imageLine } : null, jobs: [{ uses: null, steps, images: docker ? [docker] : [] }] }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A workflow (`kind` 'workflow') or an action's metadata ('action') read, or
|
|
98
|
+
// the YamlError of one GitHub can't run.
|
|
99
|
+
export function readWorkflow(kind, text) {
|
|
100
|
+
try {
|
|
101
|
+
const file = { lines: text.split('\n'), positions: new WeakMap() }
|
|
102
|
+
const doc = parseYaml(text, { workflow: true, lines: file.positions })
|
|
103
|
+
if (!isMap(doc)) throw new YamlError(`${kind === 'action' ? 'an action' : 'a workflow'} is not a mapping`)
|
|
104
|
+
if (kind === 'action') return { triggers: [], ...runsOf(file, doc) }
|
|
105
|
+
return { triggers: triggersOf(doc.on), using: null, usingLine: null, dockerfile: null, jobs: jobsOf(file, doc) }
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof YamlError) return error
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
}
|
package/risk/files/hosts.js
CHANGED
|
@@ -82,9 +82,9 @@ function githubNamed(url) {
|
|
|
82
82
|
|
|
83
83
|
// The hosts a script's strings name, each with the line it is first named on,
|
|
84
84
|
// in the order named, NPM_MAX_HOSTS at most; null for none, or for a file
|
|
85
|
-
// that is no script.
|
|
86
|
-
export function npmHosts(path, text) {
|
|
87
|
-
if (!npmIsScript(path, text) || !(text.includes('://') || text.includes(':\\/\\/'))) return null
|
|
85
|
+
// that is no script, by its name or as node runs it (`script`, npmIsScript).
|
|
86
|
+
export function npmHosts(path, text, script = false) {
|
|
87
|
+
if (!npmIsScript(path, text, script) || !(text.includes('://') || text.includes(':\\/\\/'))) return null
|
|
88
88
|
const hosts = new Map(), line = lineOf(text)
|
|
89
89
|
for (const { 1: token, index } of text.matchAll(JS_STRING_OR_COMMENT)) {
|
|
90
90
|
if (hosts.size >= NPM_MAX_HOSTS) break
|
|
@@ -172,9 +172,9 @@ const SAYS_ANY = new RegExp([...EXFILTRATION.values()].map(({ pattern }) => patt
|
|
|
172
172
|
|
|
173
173
|
// The services a file names, each with the lines it is named on, the first
|
|
174
174
|
// NPM_MAX_FOUND, in EXFILTRATION's order; null for none, or for prose or a
|
|
175
|
-
// source map.
|
|
176
|
-
export function npmExfiltration(path, text) {
|
|
177
|
-
if (typeof text !== 'string' || NPM_PROSE.test(path) || SOURCE_MAP.test(path) || !SAYS_ANY.test(text)) return null
|
|
175
|
+
// source map by its name that node doesn't run as a script (`script`).
|
|
176
|
+
export function npmExfiltration(path, text, script = false) {
|
|
177
|
+
if (typeof text !== 'string' || (!script && (NPM_PROSE.test(path) || SOURCE_MAP.test(path))) || !SAYS_ANY.test(text)) return null
|
|
178
178
|
const found = new Map(), line = lineOf(text)
|
|
179
179
|
for (const [service, { pattern }] of EXFILTRATION) {
|
|
180
180
|
const lines = new Set()
|
|
@@ -45,30 +45,34 @@ export function npmObfuscation(text) {
|
|
|
45
45
|
|
|
46
46
|
// A code file this large is mostly a bundle, worth a look where a version
|
|
47
47
|
// adds it: JavaScript or TypeScript, not a declaration file, which holds no
|
|
48
|
-
// code that runs.
|
|
48
|
+
// code that runs, or any file node runs as a script (`script`, npm/files.js
|
|
49
|
+
// npmLoadedScripts).
|
|
49
50
|
export const NPM_LARGE_CODE = 256 * 1024
|
|
50
51
|
const CODE = /\.(?:[cm]?[jt]s|[jt]sx)$/iu
|
|
51
52
|
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
|
+
export const npmLargeCode = (path, bytes, script = false) => typeof bytes === 'number' && bytes >= NPM_LARGE_CODE && (script || (CODE.test(path) && !DECLARATION.test(path)))
|
|
53
54
|
|
|
54
55
|
// A code file that isn't text (npmFileReadability's `binary`: not UTF-8, or
|
|
55
|
-
// holding a NUL), by its path and how it reads, a declaration file too
|
|
56
|
-
//
|
|
56
|
+
// holding a NUL), by its path and how it reads, a declaration file too, or
|
|
57
|
+
// any file node runs as a script (`script`, npm/files.js npmLoadedScripts):
|
|
58
|
+
// no one can read it in review, yet what reads it by its byte order mark can
|
|
57
59
|
// run it, or type with it, as UTF-16 (a browser's classic script, Windows
|
|
58
|
-
// Script Host, TypeScript)
|
|
59
|
-
|
|
60
|
+
// Script Host, TypeScript), and node runs it decoded as UTF-8 whatever it
|
|
61
|
+
// holds past its code (`//` and a byte no UTF-8 has).
|
|
62
|
+
export const npmBinaryCode = (path, readability, script = false) => readability?.category === 'binary' && (script || CODE.test(path))
|
|
60
63
|
|
|
61
64
|
// A file with a line over UNREADABLE_LINE characters, by how it reads
|
|
62
65
|
// (`now`, npmFileReadability's), `path` its path: an inline source map's line
|
|
63
66
|
// counts for none, and prose and source maps, whose long lines the Long lines
|
|
64
|
-
// tag passes over, never are,
|
|
65
|
-
//
|
|
66
|
-
// UNREADABLE_LINE, by how it reads on
|
|
67
|
+
// tag passes over, never are, but where node runs it as a script (`script`),
|
|
68
|
+
// nor is a file that isn't text. Then one that had no line over SHORT_LINES
|
|
69
|
+
// characters (`was`) and now has one over UNREADABLE_LINE, by how it reads on
|
|
70
|
+
// each side.
|
|
67
71
|
const SHORT_LINES = 200
|
|
68
72
|
const UNREADABLE_LINE = 2000
|
|
69
|
-
export const npmUnreadable = (path, now) => Boolean(now) && now.category !== 'binary' && !NPM_PROSE.test(path) && !SOURCE_MAP.test(path)
|
|
73
|
+
export const npmUnreadable = (path, now, script = false) => Boolean(now) && now.category !== 'binary' && (script || (!NPM_PROSE.test(path) && !SOURCE_MAP.test(path)))
|
|
70
74
|
&& now.longest > UNREADABLE_LINE
|
|
71
|
-
export const npmBecameUnreadable = (path, was, now) => Boolean(was) && was.category !== 'binary' && was.longest <= SHORT_LINES && npmUnreadable(path, now)
|
|
75
|
+
export const npmBecameUnreadable = (path, was, now, script = false) => Boolean(was) && was.category !== 'binary' && was.longest <= SHORT_LINES && npmUnreadable(path, now, script)
|
|
72
76
|
|
|
73
77
|
// How a file reads, as its `category`, the first that holds (READABILITY):
|
|
74
78
|
// binary (no text), map (a source map), long (code with some lines
|
|
@@ -85,8 +89,9 @@ export const npmBecameUnreadable = (path, was, now) => Boolean(was) && was.categ
|
|
|
85
89
|
// short ones (strings and comments aside, see MINIFIED_AVERAGE; 0 otherwise),
|
|
86
90
|
// how long its inline map is (`inlineMap`, 0 for none), and its obfuscator
|
|
87
91
|
// fingerprint (`obfuscation`, npmObfuscation's; null for none, and for prose
|
|
88
|
-
// or a source map).
|
|
89
|
-
|
|
92
|
+
// or a source map). One node runs as a script whatever its name (`script`,
|
|
93
|
+
// script.js npmIsScript) is read as code: no prose nor source map.
|
|
94
|
+
export function npmFileReadability(path, text, script = false) {
|
|
90
95
|
const encoding = npmTextEncoding(text)
|
|
91
96
|
if (encoding.kind === 'binary') return { ...encoding, category: 'binary', longest: 0, longLines: 0, average: 0, inlineMap: 0, obfuscation: null }
|
|
92
97
|
let inlineMap = 0, longChars = 0, longLines = 0, longest = 0
|
|
@@ -106,14 +111,15 @@ export function npmFileReadability(path, text) {
|
|
|
106
111
|
}
|
|
107
112
|
// A source map holds the names and sources of the code it maps, and prose
|
|
108
113
|
// its examples: neither runs.
|
|
109
|
-
const
|
|
114
|
+
const map = !script && SOURCE_MAP.test(path), prose = !script && NPM_PROSE.test(path)
|
|
115
|
+
const obfuscation = prose || map ? null : npmObfuscation(text)
|
|
110
116
|
const read = { ...encoding, longest, longLines, average: 0, inlineMap, obfuscation }
|
|
111
|
-
if (
|
|
112
|
-
if (
|
|
117
|
+
if (map) return { ...read, category: 'map' }
|
|
118
|
+
if (prose) return { ...read, category: inlineMap > 0 ? 'inline-map' : encoding.kind }
|
|
113
119
|
if (longLines === 0) {
|
|
114
120
|
// None of its lines longer than MINIFIED_AVERAGE, none of its code's can
|
|
115
121
|
// average more: told without reading its code.
|
|
116
|
-
const language = minifiable(path)
|
|
122
|
+
const language = minifiable(path, script)
|
|
117
123
|
const average = language !== null && longest > MINIFIED_AVERAGE ? minifiedAverage(text, language) : 0
|
|
118
124
|
return { ...read, average, category: inlineMap > 0 ? 'inline-map' : average > 0 ? 'minified' : encoding.kind }
|
|
119
125
|
}
|
package/risk/files/runs-code.js
CHANGED
|
@@ -69,7 +69,7 @@ const ENV_WHOLE = String.raw`(?=JSON|Object|URLSearchParams)(?<![\p{ID_Continue}
|
|
|
69
69
|
// BusyBox's `ash`, `| powershell`); after a pipe, by its path or env's too (`| /bin/sh`,
|
|
70
70
|
// `| /usr/bin/env bash`), env's options, their values, and settings before
|
|
71
71
|
// it on its line (`env -S`, `env -u NAME`, `env MODE=x`).
|
|
72
|
-
const SHELLS = String.raw`(?:(?:ba|z|da|k|mk|c|tc|fi|a)?sh|pwsh|powershell)`
|
|
72
|
+
export const SHELLS = String.raw`(?:(?:ba|z|da|k|mk|c|tc|fi|a)?sh|pwsh|powershell)`
|
|
73
73
|
const SHELL_PATH = String.raw`(?:(?:\/[\w.-]{1,40}){0,8}\/)?(?:env(?:[^\S\n]+(?:-[\w-]{1,20}(?:=\S{0,100}|[^\S\n]+(?!-)[^\s=]{1,100})?|\w{1,40}=\S{0,100})){0,6}[^\S\n]+)?`
|
|
74
74
|
const SHELL_OR_PLACE = String.raw`curl|wget|\|\s*(?:sudo|${SHELL_PATH}${SHELLS})|powershell|pwsh|rundll32|osascript|mshta|certutil|bitsadmin|\/dev\/(?:tcp|udp)|iex|invoke-expression`
|
|
75
75
|
+ String.raw`|\$profile|profile\.ps1|config\.fish|conf\.d|bashrc|zshrc|zshenv|zlogin|bash_profile|bash_login|zprofile|\.profile|Launch(?:Agents|Daemons)|systemd|systemctl|\.claude|\.vscode|trufflehog|metadata\.google\.internal|["'\x60\/\\]gh(?:\.exe)?["'\x60]`
|
|
@@ -118,16 +118,27 @@ const ABSENT_ENDS = String.raw`\s{0,4}(?:(?:\)\s{0,4}){1,4}(?![\s)]{0,8}(?:[.[(]
|
|
|
118
118
|
const TOKEN_NAME = String.raw`(?=NPM|GITHUB)(?<![\p{ID_Continue}$])(?<!\b(?:const|let|var)\s{1,4})`
|
|
119
119
|
+ String.raw`(?:(?<!(?:!|\bdelete\s)[\s(]{0,4}(?:[\p{ID_Continue}$]{1,64}\s{0,4}\??\.\s{0,4}){0,4})|(?!${TOKEN_VAR}${ABSENT_ENDS}))`
|
|
120
120
|
+ String.raw`${TOKEN_VAR}(?![\p{ID_Continue}$])(?!${TYPED})(?:(?!${SET})|(?<=[{,]\s{0,64}${TOKEN_VAR}))`
|
|
121
|
+
// eval as a key, not read: a property's name or a member typed, coming
|
|
122
|
+
// first or after another (`{ eval: x }`, WebAuthn's `extensions: { prf: {
|
|
123
|
+
// eval: { first } } }`, `const { eval: run } = vm`, `{ a: 1, eval: 2 }`, `a;
|
|
124
|
+
// eval: T`); not where anything else comes before it, as a ternary's choice
|
|
125
|
+
// does, whole or last of its branch (`c ? eval : f`, `c ? x && eval : f`),
|
|
126
|
+
// nor a worker told to run its string as code (`new Worker(code, { eval:
|
|
127
|
+
// true })`, minified `{eval:!0}`), nor eval destructured from the global
|
|
128
|
+
// object (`const { eval: run } = globalThis`, `({ a, eval: run } = window)`).
|
|
129
|
+
const KEYED = String.raw`(?<=[{,;]${GAP}eval)\s*:(?!${GAP}(?:true|!0)(?![\p{ID_Continue}$]))`
|
|
130
|
+
+ String.raw`(?![^{};]{0,200}\}${GAP}=${GAP}(?:${GLOBAL})(?![\p{ID_Continue}$]))`
|
|
121
131
|
// A script's strings, regular expressions and comments, as script.js
|
|
122
132
|
// tells them, each taking its own text, and its code in none of them: eval,
|
|
123
133
|
// not a property TypeScript types as optional (`eval?: T`, JavaScript's `?:`
|
|
124
|
-
// none of its own), and Function called, but to find the
|
|
134
|
+
// none of its own) nor a key (KEYED), and Function called, but to find the
|
|
135
|
+
// global object;
|
|
125
136
|
// Module's _compile, called
|
|
126
137
|
// (`module._compile(source, filename)`) or hooked (`Module.prototype._compile
|
|
127
138
|
// = …`), not named in a type (`typeof Module.prototype._compile`); a token's
|
|
128
139
|
// name, its value read; the environment read out whole; and a PUT request's
|
|
129
140
|
// call (`axios.put(…)`).
|
|
130
|
-
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})`
|
|
141
|
+
const READS = new RegExp(String.raw`${JS_STRING_OR_COMMENT.source}|(?<eval>${named('eval')}(?![\p{ID_Continue}$])(?!\s*\?:)(?!${KEYED}))|(?<function>${named('Function')}${FUNCTION_CALLED}${GLOBAL_FOUND})`
|
|
131
142
|
+ String.raw`|(?<compile>\.${GAP}_compile(?:${FUNCTION_CALLED}|${GAP}=(?![=>])))|(?<tokens>${TOKEN_NAME})|(?<env>${ENV_WHOLE})|(?<put>\.${GAP}put${GAP}\()`, 'gmu')
|
|
132
143
|
// What a string holds that a kind is: a credential file's path, npm's, SSH's
|
|
133
144
|
// or AWS's (`~/.npmrc`, `.ssh/id_rsa`, `.aws/credentials`, not `x.npmrc` or
|
|
@@ -400,12 +411,13 @@ export const RUNS_CODE = new Map([
|
|
|
400
411
|
|
|
401
412
|
// The kinds a file's code holds, each with the lines it is on, of the first
|
|
402
413
|
// NPM_MAX_FOUND places it does each, in RUNS_CODE's order; null for none, or
|
|
403
|
-
// for a file that is no script
|
|
414
|
+
// for a file that is no script, by its name or as node runs it (`nodeRuns`,
|
|
415
|
+
// npmIsScript's `script`). A shell script's, read whole, are its shell
|
|
404
416
|
// commands and the places it names; one with a shell's hashbang is one
|
|
405
417
|
// whatever its extension, and read as JavaScript too where it is that, as
|
|
406
418
|
// a launcher (`#!/bin/sh` then `exec node "$0"`) is both.
|
|
407
|
-
export function npmRunsCode(path, text) {
|
|
408
|
-
const script = npmIsScript(path, text)
|
|
419
|
+
export function npmRunsCode(path, text, nodeRuns = false) {
|
|
420
|
+
const script = npmIsScript(path, text, nodeRuns)
|
|
409
421
|
const shell = typeof text === 'string' && (SHELL_SCRIPT.test(path) || SHELL_HASHBANG.test(text))
|
|
410
422
|
if (!script && !shell) return null
|
|
411
423
|
const found = new Map()
|
package/risk/files/script.js
CHANGED
|
@@ -50,7 +50,9 @@ const CSS = {
|
|
|
50
50
|
// JavaScript's strings and regular expressions (the first group) and its
|
|
51
51
|
// comments, for a reader that sets them aside (runs-code.js, hosts.js).
|
|
52
52
|
export const JS_STRING_OR_COMMENT = JS.stringOrComment
|
|
53
|
-
|
|
53
|
+
// A stylesheet's by its name, else JavaScript's for a script by its name, or
|
|
54
|
+
// for any other where node runs it as one (`script`).
|
|
55
|
+
export const minifiable = (path, script = false) => /\.css$/iu.test(path) ? CSS : script || /\.[cm]?js$/iu.test(path) ? JS : null
|
|
54
56
|
// How much of a text is read for how its lines are minified: their average
|
|
55
57
|
// is told by as much of them, and a text of millions of lines is read no
|
|
56
58
|
// further.
|
|
@@ -81,8 +83,10 @@ export function minifiedAverage(whole, { stringOrComment, droppable, needed }) {
|
|
|
81
83
|
// as an ES module (in a `"type": "module"` package).
|
|
82
84
|
const SCRIPT = /\.[cm]?[jt]sx?$/iu
|
|
83
85
|
const NODE_HASHBANG = /^\uFEFF?#!.*\bnode\b/u
|
|
84
|
-
// Whether a file (its path and text) is a script, as SCRIPT and NODE_HASHBANG
|
|
85
|
-
|
|
86
|
+
// Whether a file (its path and text) is a script, as SCRIPT and NODE_HASHBANG
|
|
87
|
+
// tell, or as node runs it whatever its name (`script`, npm/files.js
|
|
88
|
+
// npmLoadedScripts).
|
|
89
|
+
export const npmIsScript = (path, text, script = false) => typeof text === 'string' && (script || SCRIPT.test(path) || NODE_HASHBANG.test(text))
|
|
86
90
|
// How many places a file is told doing a thing, the first ones, at most
|
|
87
91
|
// (runs-code.js, hosts.js): a file doing it on millions of lines is told by
|
|
88
92
|
// its first, and what it holds of them kept to a bound however many there
|
package/risk/files/stamps.js
CHANGED
|
@@ -22,12 +22,12 @@ const severityOf = mark => mark === 'warn' ? 'high' : 'notice'
|
|
|
22
22
|
// obfuscator's fingerprint, a warning's; and its control characters, long
|
|
23
23
|
// lines and category, by their marks, but for what reads as most files do
|
|
24
24
|
// (UTF-8, ASCII) and a source map.
|
|
25
|
-
function npmStampsOf({ path, bytes, readability, runsCode, exfiltration }) {
|
|
25
|
+
function npmStampsOf({ path, bytes, readability, runsCode, exfiltration, script }) {
|
|
26
26
|
const category = npmTaggedCategory(readability)
|
|
27
27
|
const listed = category !== 'ascii' && category !== 'map' && READABILITY.get(category).mark
|
|
28
28
|
return [...npmRunsCodeFlags(runsCode),
|
|
29
29
|
...npmHostTags(exfiltration).map(([tag, services]) => ({ name: tag, severity: npmHostSeverity(tag, services) })),
|
|
30
|
-
...npmLargeCode(path, bytes) ? [{ name: 'Large', severity: 'notice' }] : [],
|
|
30
|
+
...npmLargeCode(path, bytes, script) ? [{ name: 'Large', severity: 'notice' }] : [],
|
|
31
31
|
...readability.obfuscation ? [{ name: 'Obfuscated', severity: 'high' }] : [],
|
|
32
32
|
...npmReadabilityLabels(readability).filter(label => label === 'controls' || label === 'long')
|
|
33
33
|
.map(label => ({ name: READABILITY.get(label).tag, severity: severityOf(npmLabelMark(readability, label)) })),
|
|
@@ -38,9 +38,10 @@ function npmStampsOf({ path, bytes, readability, runsCode, exfiltration }) {
|
|
|
38
38
|
// isn't, and its size in `bytes`): how it reads (npmFileReadability), what
|
|
39
39
|
// its code does beyond itself (npmRunsCode), the services it names under the
|
|
40
40
|
// hosts' tags (npmExfiltration) and the hosts its code names (npmHosts), and its
|
|
41
|
-
// `stamps` (npmStampsOf)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
41
|
+
// `stamps` (npmStampsOf); read as a script where node runs it as one whatever
|
|
42
|
+
// its name (`script`, npm/files.js npmLoadedScripts).
|
|
43
|
+
export function npmFileRisk(path, text, bytes, script = false) {
|
|
44
|
+
const readability = npmFileReadability(path, text, script)
|
|
45
|
+
const exfiltration = npmExfiltration(path, text, script), runsCode = npmRunsCode(path, text, script)
|
|
46
|
+
return { readability, runsCode, exfiltration, hosts: npmHosts(path, text, script), stamps: npmStampsOf({ path, bytes, readability, runsCode, exfiltration, script }) }
|
|
46
47
|
}
|
package/risk/index.d.ts
CHANGED
|
@@ -179,4 +179,74 @@ export function npmReadyDownload(name: string, options: { cache: CacheStore; sig
|
|
|
179
179
|
// malware.
|
|
180
180
|
export function npmMalwareAdvisory(advisory: { id?: string | undefined; aliases?: readonly string[] | undefined; cwe?: readonly string[] | undefined; title?: string | undefined }): boolean
|
|
181
181
|
|
|
182
|
-
export function npmFileRisk(path: string, text: string | null, bytes?: number): unknown
|
|
182
|
+
export function npmFileRisk(path: string, text: string | null, bytes?: number, script?: boolean): unknown
|
|
183
|
+
|
|
184
|
+
// GitHub Actions (actions/), a workflow GitHub can't run thrown as
|
|
185
|
+
// @preventive/lockfile's YamlError. A finding of a workflow's, its `line` from 1; of a reference's own code,
|
|
186
|
+
// with the `file` it is in.
|
|
187
|
+
export interface ActionsFinding extends Finding {
|
|
188
|
+
line?: number | null
|
|
189
|
+
file?: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// What a `uses:` names: an action's or a reusable workflow's, by its `key`,
|
|
193
|
+
// or the repository's own.
|
|
194
|
+
export type ActionUses = { kind: 'action' | 'workflow'; uses: string; repo: string; path: string; ref: string; pin: 'sha' | 'short-sha' | 'ref' | 'expression'; comment: string | null; key?: string; line?: number | null }
|
|
195
|
+
export type Uses = ActionUses | { kind: 'local'; uses: string; path: string; self?: true; job?: true; line?: number | null }
|
|
196
|
+
|
|
197
|
+
export interface ScanOptions {
|
|
198
|
+
// Whose repository it is, as an unpinned `uses:` of its owner's is yellow.
|
|
199
|
+
owner?: string | null
|
|
200
|
+
// Its lockfiles' paths, the packages each locks, as npx runs those, by
|
|
201
|
+
// name and by `name@version`, and those only devDependencies need.
|
|
202
|
+
lockfiles?: { path: string; names: ReadonlySet<string>; dev?: ReadonlySet<string> }[]
|
|
203
|
+
// Its own actions' metadata by their directories, and its workflows by
|
|
204
|
+
// their paths: for where its actions install its packages, else each
|
|
205
|
+
// counts as installing them anywhere; and for the inputs its actions and
|
|
206
|
+
// reusable workflows write into their scripts, else each of a reusable
|
|
207
|
+
// workflow's counts as one.
|
|
208
|
+
actions?: ReadonlyMap<string, string> | null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type WorkflowScan = { uses: Uses[]; findings: ActionsFinding[] } | { error: 'workflow-unreadable'; detail: string }
|
|
212
|
+
|
|
213
|
+
export function scanWorkflow(kind: 'workflow' | 'action', text: string, options?: ScanOptions): WorkflowScan
|
|
214
|
+
// The actions of the repository's own a workflow or an action uses, by
|
|
215
|
+
// their directories, and a Docker action's Dockerfile, from its own.
|
|
216
|
+
export function ownFilesOf(kind: 'workflow' | 'action', text: string): { actions: string[]; dockerfile: string | null }
|
|
217
|
+
// A Dockerfile's findings, each on its line.
|
|
218
|
+
export function dockerfileFindings(text: string): ActionsFinding[]
|
|
219
|
+
// A reference by its key, as a scan names it; null for none.
|
|
220
|
+
export function usesOfKey(key: string): ActionUses | null
|
|
221
|
+
|
|
222
|
+
// GitHub as the asker can read it; null for none.
|
|
223
|
+
export interface ActionsSource {
|
|
224
|
+
// Its name now, where it moved, and the repository it forks.
|
|
225
|
+
repo(name: string): Promise<{ fullName: string; defaultBranch: string; fork?: string | null; archived?: boolean; private?: boolean } | null>
|
|
226
|
+
// Every tag's commit; null where it has too many to list.
|
|
227
|
+
tags(name: string): Promise<{ tag: string; oid: string }[] | null>
|
|
228
|
+
tag(name: string, tag: string): Promise<string | null>
|
|
229
|
+
branch(name: string, branch: string): Promise<string | null>
|
|
230
|
+
// Its branches, and whether those are all.
|
|
231
|
+
branches(name: string): Promise<{ branches: { branch: string; oid: string }[]; complete: boolean }>
|
|
232
|
+
// Whether `sha` is `base` or in its history; null for no such commit. Of two
|
|
233
|
+
// commits, it holds for good.
|
|
234
|
+
contains(name: string, base: string, sha: string): Promise<boolean | null>
|
|
235
|
+
file(name: string, path: string, commit: string): Promise<string | null>
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A reference resolved: its repository as GitHub has it now, its ref's
|
|
239
|
+
// commit, what the ref is, the tags on the commit, and the keys of the
|
|
240
|
+
// references its code makes; null for none.
|
|
241
|
+
export interface ResolvedAction extends Pick<ActionUses, 'kind' | 'repo' | 'path' | 'ref'> {
|
|
242
|
+
key: string
|
|
243
|
+
repository: { fullName: string; private: boolean } | null
|
|
244
|
+
commit: string | null
|
|
245
|
+
at: 'sha' | 'tag' | 'branch' | null
|
|
246
|
+
tags: string[]
|
|
247
|
+
findings: ActionsFinding[]
|
|
248
|
+
uses: string[]
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function resolveAction(uses: ActionUses, source: ActionsSource, options?: { owner?: string | null }): Promise<ResolvedAction>
|
|
252
|
+
export function keptSource(source: ActionsSource): ActionsSource
|
package/risk/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// What calls for a look at a package version,
|
|
2
|
-
// heuristics, apart from any view of them.
|
|
1
|
+
// What calls for a look at a package version, or a repository's GitHub
|
|
2
|
+
// Actions, for a reviewer: risk's heuristics, apart from any view of them.
|
|
3
|
+
// See README.md.
|
|
3
4
|
export { assessNpm } from './npm/assess.js'
|
|
4
5
|
export { npmMalwareAdvisory } from './npm/malware.js'
|
|
5
6
|
export { DEFAULT_MINIMUM_RELEASE_AGE } from './npm/release.js'
|
|
6
7
|
export { NpmRiskError, npmReadyDownload, npmReadyDownloads } from './npm/registry.js'
|
|
7
8
|
export { SEVERITIES, npmFileRisk } from './files/stamps.js'
|
|
9
|
+
export { ownFilesOf, scanWorkflow } from './actions/findings.js'
|
|
10
|
+
export { dockerfileFindings, keptSource, resolveAction } from './actions/resolve.js'
|
|
11
|
+
export { usesOfKey } from './actions/uses.js'
|
package/risk/npm/assess.js
CHANGED
|
@@ -19,7 +19,7 @@ import { npmRiskScore } from './score.js'
|
|
|
19
19
|
// Which reading of a version an assessment kept (`risk/npm/assessments`) is:
|
|
20
20
|
// one more whenever a heuristic changes what it finds, so one kept by an
|
|
21
21
|
// older reading is read anew.
|
|
22
|
-
const RISK_VERSION =
|
|
22
|
+
const RISK_VERSION = 18
|
|
23
23
|
|
|
24
24
|
// How long an assessment kept may be as JSON, and what risk reads of either
|
|
25
25
|
// version's files: a package past it, whose files name thousands of hosts or
|
package/risk/npm/compare.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
// files the open version adds, and `changed`, those it changes, each `{ path,
|
|
9
9
|
// renamedFrom?, modified? }`, by its path in the open version.
|
|
10
10
|
import { bundleFileSizes, bundleFilesAsMap } from '../../common/bundle-sources.js'
|
|
11
|
-
import { npmVersionFileRisk, npmVersionHosts } from './files.js'
|
|
11
|
+
import { npmLoadedScripts, npmVersionFileRisk, npmVersionHosts } from './files.js'
|
|
12
12
|
import { npmHostFinding } from './held.js'
|
|
13
13
|
import { HOST_TAGS, npmHostSeverity, npmHostsTagged } from '../files/hosts.js'
|
|
14
14
|
import { npmBecameUnreadable, npmBinaryCode, npmLargeCode, npmUnreadable } from '../files/readability.js'
|
|
15
|
+
import { npmIsScript } from '../files/script.js'
|
|
15
16
|
import { SEVERITIES } from '../files/stamps.js'
|
|
16
17
|
import { CREDENTIAL_TAGS, npmFoundAdded, npmRunsCodeFlags, npmRunsCodeTagged, npmRunsCodeWithout } from '../files/runs-code.js'
|
|
17
18
|
import { npmDependencySpecs } from './dependencies.js'
|
|
@@ -19,6 +20,21 @@ import { npmFilesDiff } from './diff.js'
|
|
|
19
20
|
import { npmInstallScripts } from './install-scripts.js'
|
|
20
21
|
import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from './release.js'
|
|
21
22
|
|
|
23
|
+
// The files the open version (`other`) runs as scripts that the one compared
|
|
24
|
+
// with (`base`) held, changed or not, but didn't run, by their names
|
|
25
|
+
// (script.js npmIsScript, a `.js` renamed `.txt`) or as code loaded them
|
|
26
|
+
// (npm/files.js npmLoadedScripts), as its code now loads them, each a row as npmFilesDiff's
|
|
27
|
+
// `changed` holds one, its own where it has one: what node runs of them is
|
|
28
|
+
// new, as a changed file's is.
|
|
29
|
+
function npmWokenFiles(base, other, files) {
|
|
30
|
+
const added = new Set(files.onlyOther.map(row => row.path)), rows = new Map(files.changed.map(row => [row.path, row]))
|
|
31
|
+
const before = bundleFilesAsMap(base), loadedBefore = npmLoadedScripts(base)
|
|
32
|
+
// A file that isn't text by its name alone (`payload.js`).
|
|
33
|
+
const ran = path => loadedBefore.has(path) || (before.has(path) && npmIsScript(path, typeof before.get(path) === 'string' ? before.get(path) : ''))
|
|
34
|
+
return [...npmLoadedScripts(other)].filter(path => !added.has(path)).map(path => rows.get(path) ?? { path })
|
|
35
|
+
.filter(row => !ran(row.renamedFrom ?? row.path))
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
// What the open version does to its files that calls for a look, as red
|
|
23
39
|
// findings: code that runs beyond itself (runs-code.js), new to a file it
|
|
24
40
|
// adds or changes, the kinds with their lines (`runs-code`), those reaching
|
|
@@ -26,7 +42,8 @@ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from '
|
|
|
26
42
|
// one of the hosts' tags new to a file it adds or changes (hosts.js
|
|
27
43
|
// npmExfiltration), a finding a tag a level higher than its own
|
|
28
44
|
// (npmHostSeverity: `exfiltration` and `remote` red, `ip-lookup` orange); a
|
|
29
|
-
// code file of 256 KiB or more it adds
|
|
45
|
+
// code file of 256 KiB or more it adds, one node runs as a script whatever
|
|
46
|
+
// its name too (npmLargeCode, `large-code`); a file
|
|
30
47
|
// it changes that had no line over 200 characters and now has one over 2,000
|
|
31
48
|
// (npmBecameUnreadable, `now-unreadable`); a file it adds or changes with
|
|
32
49
|
// an obfuscator's fingerprint where the one compared with had none
|
|
@@ -39,13 +56,17 @@ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from '
|
|
|
39
56
|
// where its source map was rebuilt with it (`rebuilt`, rebuiltWithMap), as a
|
|
40
57
|
// build minifies a file and writes its map together, and in a `major`
|
|
41
58
|
// release (npmMajorRelease): `now-unreadable` orange where every such file is
|
|
42
|
-
// either, else red.
|
|
59
|
+
// either, else red. A file it held, changed or not, that node now runs as a
|
|
60
|
+
// script (npmWokenFiles) counts as one it changes, large code where it is
|
|
61
|
+
// one, and unreadable now where it wasn't as no script, whatever its lines
|
|
62
|
+
// were.
|
|
43
63
|
function npmFileFindings(base, other, files, major) {
|
|
44
64
|
const added = new Set(files.onlyOther)
|
|
45
65
|
const after = row => npmVersionFileRisk(other, row.path)
|
|
46
66
|
const before = row => added.has(row) ? null : npmVersionFileRisk(base, row.renamedFrom ?? row.path)
|
|
47
67
|
const changed = files.changed.filter(row => row.renamedFrom == null || row.modified)
|
|
48
|
-
const
|
|
68
|
+
const woken = npmWokenFiles(base, other, files)
|
|
69
|
+
const touched = [...new Set([...files.onlyOther, ...changed, ...woken])]
|
|
49
70
|
const sizes = bundleFileSizes(other)
|
|
50
71
|
// The files that hold what they didn't before, each with that.
|
|
51
72
|
const gained = key => touched.map(row => ({ path: row.path, added: npmFoundAdded(after(row)[key], before(row)?.[key] ?? null) })).filter(row => row.added)
|
|
@@ -61,13 +82,16 @@ function npmFileFindings(base, other, files, major) {
|
|
|
61
82
|
const rows = exfiltration.map(row => ({ path: row.path, added: npmHostsTagged(row.added, tag) })).filter(row => row.added)
|
|
62
83
|
return { id: npmHostFinding(tag), severity: rows.length > 0 ? npmHostSeverity(tag, rows[0].added, null) : HOST_TAGS.get(tag), files: rows }
|
|
63
84
|
}),
|
|
64
|
-
{ id: 'large-code', severity: 'critical', files: files.onlyOther.filter(row => npmLargeCode(row.path, sizes.get(row.path)
|
|
65
|
-
|
|
85
|
+
{ id: 'large-code', severity: 'critical', files: [...files.onlyOther, ...woken].filter(row => npmLargeCode(row.path, sizes.get(row.path), npmLoadedScripts(other).has(row.path)))
|
|
86
|
+
.map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
|
|
87
|
+
{ id: 'now-unreadable', ...unreadableFinding(major, [...new Set([...changed.filter(row => npmBecameUnreadable(row.path, before(row)?.readability, after(row).readability, npmLoadedScripts(other).has(row.path))),
|
|
88
|
+
...woken.filter(row => npmUnreadable(row.path, after(row).readability, true) && !npmUnreadable(row.renamedFrom ?? row.path, before(row).readability))])]
|
|
66
89
|
.map(row => ({ path: row.path, was: before(row).readability.longest, now: after(row).readability.longest,
|
|
67
90
|
...rebuiltWithMap(base, other, row.path, row.renamedFrom ?? row.path) ? { rebuilt: true } : {} }))) },
|
|
68
91
|
{ id: 'obfuscated', severity: 'critical', files: touched.filter(row => after(row).readability.obfuscation && !before(row)?.readability.obfuscation)
|
|
69
92
|
.map(row => ({ path: row.path, obfuscation: after(row).readability.obfuscation })) },
|
|
70
|
-
{ id: 'binary-code', severity: 'critical', files: touched.filter(row => npmBinaryCode(row.path, after(row).readability
|
|
93
|
+
{ id: 'binary-code', severity: 'critical', files: touched.filter(row => npmBinaryCode(row.path, after(row).readability, npmLoadedScripts(other).has(row.path))
|
|
94
|
+
&& !npmBinaryCode(row.renamedFrom ?? row.path, before(row)?.readability, npmLoadedScripts(base).has(row.renamedFrom ?? row.path)))
|
|
71
95
|
.map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
|
|
72
96
|
{ id: 'kept-runs-code', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeWithout) },
|
|
73
97
|
{ id: 'kept-credentials', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeTagged) },
|
|
@@ -149,7 +173,7 @@ const unreadableFinding = (major, files) => ({ severity: major || files.every(ro
|
|
|
149
173
|
// adds or changes hold all it names anew.
|
|
150
174
|
function npmNewHosts(base, other, files) {
|
|
151
175
|
const found = new Map(), known = npmVersionHosts(base)
|
|
152
|
-
for (const
|
|
176
|
+
for (const path of new Set([...files.onlyOther, ...files.changed, ...npmWokenFiles(base, other, files)].map(row => row.path))) {
|
|
153
177
|
for (const [host, line] of npmVersionFileRisk(other, path).hosts ?? []) {
|
|
154
178
|
if (known.has(host) || ALLOWED_HOSTS.has(host)) continue
|
|
155
179
|
const place = { path, line }
|
|
@@ -179,8 +203,8 @@ export function npmBaseFilesTell(other, manifest) {
|
|
|
179
203
|
const sizes = bundleFileSizes(other)
|
|
180
204
|
return [...bundleFilesAsMap(other).keys()].some(path => {
|
|
181
205
|
const { runsCode, exfiltration, readability, hosts } = npmVersionFileRisk(other, path)
|
|
182
|
-
return Boolean(runsCode || exfiltration || readability.obfuscation) || npmUnreadable(path, readability) || npmLargeCode(path, sizes.get(path))
|
|
183
|
-
|| npmBinaryCode(path, readability)
|
|
206
|
+
return Boolean(runsCode || exfiltration || readability.obfuscation) || npmUnreadable(path, readability, npmLoadedScripts(other).has(path)) || npmLargeCode(path, sizes.get(path), npmLoadedScripts(other).has(path))
|
|
207
|
+
|| npmBinaryCode(path, readability, npmLoadedScripts(other).has(path))
|
|
184
208
|
|| [...hosts?.keys() ?? []].some(host => !ALLOWED_HOSTS.has(host))
|
|
185
209
|
})
|
|
186
210
|
}
|