@tetherto/mdk-skill 0.0.0 → 0.12.1
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/LICENSE +202 -0
- package/README.md +133 -0
- package/dist/skills/mdk/SKILL.md +114 -0
- package/dist/skills/mdk/references/architecture.md +89 -0
- package/dist/skills/mdk/references/glossary.md +27 -0
- package/dist/skills/mdk/references/mdk-contract.schema.json +138 -0
- package/dist/skills/mdk/references/package-index.md +53 -0
- package/dist/skills/mdk/references/protocol.md +95 -0
- package/dist/skills/mdk-create-app/SKILL.md +231 -0
- package/dist/skills/mdk-deployment/SKILL.md +161 -0
- package/dist/skills/mdk-gateway-plugin/SKILL.md +192 -0
- package/dist/skills/mdk-gateway-plugin/references/controller-patterns.md +99 -0
- package/dist/skills/mdk-gateway-plugin/references/plugin-authoring.md +121 -0
- package/dist/skills/mdk-publish-security/SKILL.md +177 -0
- package/dist/skills/mdk-publish-security/references/vuln-classes.md +151 -0
- package/dist/skills/mdk-publish-security/scripts/scan-packages.mjs +544 -0
- package/dist/skills/mdk-site-sizing/SKILL.md +160 -0
- package/dist/skills/mdk-site-sizing/eval/held-out.json +104 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers-mixed.json +24 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-10devices-1workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-15devices-2workers.json +24 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-200devices-4workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-500devices-4workers.json +23 -0
- package/dist/skills/mdk-site-sizing/eval/profiles/cap-50devices-2workers.json +23 -0
- package/dist/skills/mdk-site-sizing/references/envelope.json +284 -0
- package/dist/skills/mdk-site-sizing/references/envelope.md +42 -0
- package/dist/skills/mdk-site-sizing/scripts/compile-envelope.mjs +45 -0
- package/dist/skills/mdk-site-sizing/scripts/eval-held-out.mjs +50 -0
- package/dist/skills/mdk-site-sizing/scripts/lib.mjs +591 -0
- package/dist/skills/mdk-site-sizing/scripts/parse-site.mjs +25 -0
- package/dist/skills/mdk-site-sizing/scripts/size-site.mjs +49 -0
- package/dist/skills/mdk-ui-component/SKILL.md +207 -0
- package/dist/skills/mdk-ui-component/references/page-recipe.md +78 -0
- package/dist/skills/mdk-ui-component/references/ui-registry.json +1 -0
- package/dist/skills/mdk-worker-plugin/SKILL.md +198 -0
- package/dist/skills/mdk-worker-plugin/assets/mdk-contract.template.json +45 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/README.md +93 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/mdk-contract.json +26 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/mock/server.js +93 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/package.json +23 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/smoke.config.js +41 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/client.js +47 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/reboot.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/set-power-mode.js +10 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/hashrate-rt.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power-mode.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power.js +5 -0
- package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/temperature.js +5 -0
- package/dist/skills/mdk-worker-plugin/references/contract-authoring.md +135 -0
- package/dist/skills/mdk-worker-plugin/references/device-families.md +96 -0
- package/dist/skills/mdk-worker-plugin/references/local-testing.md +134 -0
- package/dist/skills/mdk-worker-plugin/references/mdk-contract.schema.json +138 -0
- package/dist/skills/mdk-worker-plugin/references/worker-base-api.md +152 -0
- package/dist/skills/mdk-worker-plugin/scripts/validate-contract.mjs +129 -0
- package/dist/skills/mdk-worker-plugin/scripts/worker-smoke.mjs +256 -0
- package/package.json +47 -1
- package/src/assemble.mjs +11 -0
- package/src/index.d.ts +57 -0
- package/src/index.mjs +254 -0
- package/src/install.mjs +57 -0
- package/src/sources.map.json +34 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Pre-publish security scan for every publishable package in an npm monorepo.
|
|
3
|
+
//
|
|
4
|
+
// node scan-packages.mjs [--root <repo>] [--pkg <dir>]... [--audit] [--smoke] [--registry]
|
|
5
|
+
// [--keep-smoke] [--json] [--out <file>]
|
|
6
|
+
//
|
|
7
|
+
// Discovers every git-tracked package.json that does not set "private": true
|
|
8
|
+
// (or only the --pkg dirs), then runs the mechanical checks from
|
|
9
|
+
// PUBLISH_CHECKLIST.md / NPM_PACKAGES.md on each one:
|
|
10
|
+
//
|
|
11
|
+
// packaging files allowlist, publishConfig.access, repository, prepack build,
|
|
12
|
+
// install scripts, file:/workspace:/link: deps, tarball contents,
|
|
13
|
+
// main/exports/bin targets present in the tarball, bin shebang
|
|
14
|
+
// deps loose ranges (*, latest, >=), deps on private workspace packages,
|
|
15
|
+
// root/package overrides that consumers will not get, dev tools
|
|
16
|
+
// declared as required peers
|
|
17
|
+
// imports relative require/import escaping the package dir, bare imports
|
|
18
|
+
// missing from dependencies
|
|
19
|
+
// secrets key/token/password patterns, private IPs and internal hosts in
|
|
20
|
+
// every file that would land in the tarball
|
|
21
|
+
// sast grep-level leads for the 18 vulnerability classes and the UI
|
|
22
|
+
// classes U1–U12 in ../references/vuln-classes.md — LEADS, not
|
|
23
|
+
// findings; triage each
|
|
24
|
+
// audit (--audit) npm audit --omit=dev in the workspace that owns each
|
|
25
|
+
// package, e.g. ui/ with its own lockfile (needs network)
|
|
26
|
+
// smoke (--smoke) npm pack every package (runs its own prepack), install all
|
|
27
|
+
// tarballs together in a scratch project outside the repo, import every
|
|
28
|
+
// main/exports entry, run every bin with --help, then npm audit that
|
|
29
|
+
// project: the consumer's view, without root overrides (needs network)
|
|
30
|
+
// registry (--registry) npm view each name: unclaimed names, versions already
|
|
31
|
+
// published, repository mismatch (squat), npm whoami (needs network)
|
|
32
|
+
//
|
|
33
|
+
// Zero-dependency. Exit 0 always unless the scan itself fails; severity is in
|
|
34
|
+
// the report. Results go to stdout as Markdown (default) or JSON (--json).
|
|
35
|
+
// --out creates a new file and fails if the path already exists (or is a symlink).
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs'
|
|
38
|
+
import os from 'node:os'
|
|
39
|
+
import path from 'node:path'
|
|
40
|
+
import { builtinModules } from 'node:module'
|
|
41
|
+
import { execFileSync } from 'node:child_process'
|
|
42
|
+
|
|
43
|
+
const args = process.argv.slice(2)
|
|
44
|
+
const opt = { root: process.cwd(), pkgs: [], audit: false, smoke: false, registry: false, keepSmoke: false, json: false, out: null }
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
const a = args[i]
|
|
47
|
+
if (a === '--root') opt.root = path.resolve(args[++i])
|
|
48
|
+
else if (a === '--pkg') opt.pkgs.push(args[++i])
|
|
49
|
+
else if (a === '--audit') opt.audit = true
|
|
50
|
+
else if (a === '--smoke') opt.smoke = true
|
|
51
|
+
else if (a === '--registry') opt.registry = true
|
|
52
|
+
else if (a === '--keep-smoke') opt.keepSmoke = true
|
|
53
|
+
else if (a === '--json') opt.json = true
|
|
54
|
+
else if (a === '--out') opt.out = args[++i]
|
|
55
|
+
else if (a === '-h' || a === '--help') {
|
|
56
|
+
console.log(fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(1, 35).join('\n'))
|
|
57
|
+
process.exit(0)
|
|
58
|
+
} else {
|
|
59
|
+
console.error(`ERR_ARG: unknown argument ${a}`)
|
|
60
|
+
process.exit(2)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fail before the (slow) scan rather than after it. The `wx` open at write time
|
|
65
|
+
// is the real guard: O_EXCL never follows a symlink planted in the meantime.
|
|
66
|
+
if (opt.out) {
|
|
67
|
+
let exists = false
|
|
68
|
+
try { fs.lstatSync(opt.out); exists = true } catch {}
|
|
69
|
+
if (exists) {
|
|
70
|
+
console.error(`ERR_ARG: --out ${opt.out} already exists (or is a symlink); choose a new path, e.g. "$(mktemp -d)/mdk-scan.md"`)
|
|
71
|
+
process.exit(2)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SELF = path.basename(new URL(import.meta.url).pathname)
|
|
76
|
+
const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)])
|
|
77
|
+
const CODE_EXT = /\.(c|m)?(j|t)sx?$/
|
|
78
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'coverage', '.nyc_output'])
|
|
79
|
+
|
|
80
|
+
function run (cmd, argv, cwd, { timeout, env } = {}) {
|
|
81
|
+
try {
|
|
82
|
+
return { ok: true, out: execFileSync(cmd, argv, { cwd, env: env && { ...process.env, ...env }, timeout, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 << 20 }) }
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { ok: false, out: String(err.stdout || ''), err: String(err.stderr || err.message), timedOut: err.signal === 'SIGTERM' }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// First line of an error that says what went wrong, not the stack.
|
|
89
|
+
function firstError (text) {
|
|
90
|
+
const lines = String(text).split('\n').map((l) => l.trim()).filter(Boolean)
|
|
91
|
+
const pick = lines.find((l) => /404 Not Found|No matching version|Cannot find|ERR_MODULE_NOT_FOUND|ETARGET/.test(l)) ||
|
|
92
|
+
lines.find((l) => /Error|ERR_|E404|ENOENT/.test(l))
|
|
93
|
+
return (pick || lines[0] || 'no output').slice(0, 240)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Every workspace package (private or not): name → { dir, private }.
|
|
97
|
+
let WORKSPACE = new Map()
|
|
98
|
+
function discover () {
|
|
99
|
+
const ls = run('git', ['ls-files', '--', '*package.json', 'package.json'], opt.root)
|
|
100
|
+
if (!ls.ok) throw new Error(`git ls-files failed: ${ls.err}`)
|
|
101
|
+
const all = ls.out.split('\n').filter(Boolean)
|
|
102
|
+
.filter((f) => !/(^|\/)(node_modules|dist|templates|fixtures?)\//.test(f))
|
|
103
|
+
.map((f) => path.resolve(opt.root, path.dirname(f)))
|
|
104
|
+
WORKSPACE = new Map(all.map((dir) => [readJson(path.join(dir, 'package.json')), dir])
|
|
105
|
+
.filter(([pj]) => pj && pj.name)
|
|
106
|
+
.map(([pj, dir]) => [pj.name, { dir, private: pj.private === true }]))
|
|
107
|
+
if (opt.pkgs.length) return opt.pkgs.map((p) => path.resolve(opt.root, p))
|
|
108
|
+
return [...WORKSPACE.values()].filter((w) => !w.private).map((w) => w.dir)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Relative file targets of an "exports" map (strings at any depth), skipping patterns.
|
|
112
|
+
function exportTargets (exp) {
|
|
113
|
+
if (typeof exp === 'string') return exp.includes('*') ? [] : [exp]
|
|
114
|
+
if (!exp || typeof exp !== 'object') return []
|
|
115
|
+
return Object.values(exp).flatMap(exportTargets)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Import specifiers a consumer can use: "name" and each non-pattern "./sub" export.
|
|
119
|
+
function entrySpecs (pj) {
|
|
120
|
+
if (pj.exports && typeof pj.exports === 'object' && Object.keys(pj.exports).some((k) => k.startsWith('.'))) {
|
|
121
|
+
return Object.keys(pj.exports).filter((k) => !k.includes('*') && k !== './package.json')
|
|
122
|
+
.map((k) => (k === '.' ? pj.name : `${pj.name}/${k.slice(2)}`))
|
|
123
|
+
}
|
|
124
|
+
return pj.main || pj.exports || !pj.bin ? [pj.name] : []
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function binEntries (pj) {
|
|
128
|
+
if (typeof pj.bin === 'string') return [[pj.name.split('/').pop(), pj.bin]]
|
|
129
|
+
return Object.entries(pj.bin || {})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const norm = (f) => path.posix.normalize(String(f).replace(/^\.\//, ''))
|
|
133
|
+
|
|
134
|
+
function readJson (f) {
|
|
135
|
+
try { return JSON.parse(fs.readFileSync(f, 'utf8')) } catch { return null }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Files npm would pack. Prefer npm's own answer; fall back to a walk.
|
|
139
|
+
function tarballFiles (dir) {
|
|
140
|
+
const r = run('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], dir)
|
|
141
|
+
if (r.ok) {
|
|
142
|
+
try {
|
|
143
|
+
const j = JSON.parse(r.out)
|
|
144
|
+
return { source: 'npm pack', files: j[0].files.map((f) => f.path), size: j[0].unpackedSize }
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
const files = []
|
|
148
|
+
const walk = (d) => {
|
|
149
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
150
|
+
if (SKIP_DIRS.has(e.name)) continue
|
|
151
|
+
const p = path.join(d, e.name)
|
|
152
|
+
if (e.isDirectory()) walk(p)
|
|
153
|
+
else files.push(path.relative(dir, p))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
walk(dir)
|
|
157
|
+
return { source: 'walk (npm pack failed)', files, size: null }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const SHIP_SMELLS = [
|
|
161
|
+
[/(^|\/)(tests?|__tests__|spec)\//, 'test code'],
|
|
162
|
+
[/(^|\/)mocks?\//, 'mock server / fixtures'],
|
|
163
|
+
[/(^|\/)fixtures?\//, 'fixtures'],
|
|
164
|
+
[/(^|\/)coverage\//, 'coverage output'],
|
|
165
|
+
[/^(?!.*(^|\/)mocks?\/)(.*\/)?(db|store)\//, 'local database / store dir'],
|
|
166
|
+
[/(^|\/)\.env(\.|$)/, '.env file'],
|
|
167
|
+
[/\.(pem|key|p12|pfx|crt|keystore)$/i, 'key / certificate file'],
|
|
168
|
+
[/(^|\/)config\/(?!.*\.example\.)[^/]+\.json$/, 'real config file (not *.example.*)'],
|
|
169
|
+
[/\.sh$/, 'shell script'],
|
|
170
|
+
[/\.(sqlite|db|log)$/i, 'database / log file'],
|
|
171
|
+
[/(^|\/)\.(npmrc|netrc)$/, 'registry credentials file']
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
const SECRET_PATTERNS = [
|
|
175
|
+
[/-----BEGIN (RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----/, 'private key block'],
|
|
176
|
+
[/AKIA[0-9A-Z]{16}/, 'AWS access key id'],
|
|
177
|
+
[/gh[pousr]_[A-Za-z0-9]{36,}/, 'GitHub token'],
|
|
178
|
+
[/npm_[A-Za-z0-9]{36}/, 'npm token'],
|
|
179
|
+
[/xox[abprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
|
|
180
|
+
[/sk-[A-Za-z0-9]{32,}/, 'API secret key (sk-)'],
|
|
181
|
+
[/AIza[0-9A-Za-z_-]{35}/, 'Google API key'],
|
|
182
|
+
[/\b(?:api[_-]?key|apikey|secret|token|passw(?:or)?d|auth)\b["']?\s*[:=]\s*["'][^"'\s$]{12,}["']/i, 'hard-coded credential literal'],
|
|
183
|
+
[/\b(?:seed|privateKey|secretKey|mnemonic)\b["']?\s*[:=]\s*["'][0-9a-f]{32,}["']/i, 'hard-coded key material (hex)'],
|
|
184
|
+
[/\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b/, 'private IP address'],
|
|
185
|
+
[/\b[a-z0-9-]+\.(internal|corp|lan|intra)\b/i, 'internal hostname']
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
// Grep-level leads per vulnerability class. Keyed to references/vuln-classes.md.
|
|
189
|
+
const SAST_RULES = [
|
|
190
|
+
['command-exec', /\bchild_process\b|\b(exec|execSync|execFile|execFileSync|spawn|spawnSync|fork)\s*\(/, 'process execution'],
|
|
191
|
+
['command-exec', /shell\s*:\s*true/, 'spawn with shell:true'],
|
|
192
|
+
['injection', /\beval\s*\(|new\s+Function\s*\(|\bvm\.(runIn|Script|compileFunction)/, 'dynamic code evaluation'],
|
|
193
|
+
['injection', /\brequire\s*\(\s*[^'"`)\s][^)]*\)|import\s*\(\s*[^'"`)\s]/, 'dynamic require/import of a computed path'],
|
|
194
|
+
['injection', /`\s*(SELECT|INSERT|UPDATE|DELETE)\b[^`]*\$\{|\.(query|exec|prepare|run|all|get)\s*\(\s*`[^`]*\$\{/i, 'SQL built with template literal'],
|
|
195
|
+
['path-traversal', /path\.(join|resolve)\s*\([^)]*\b(req|request|params|query|body|args|input|name|id|file|dir|pkg|package)\b/i, 'path built from a possibly user-controlled value'],
|
|
196
|
+
['fs-dangerous', /\bfs(\.promises)?\.(rm|rmSync|rmdir|rmdirSync|unlink|unlinkSync|rename|renameSync|chmod|chmodSync|chown|symlink|symlinkSync|copyFile|cp|cpSync)\s*\(|\brimraf\b/, 'destructive / permission-changing fs op'],
|
|
197
|
+
['fs-dangerous', /\bfs(\.promises)?\.(writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|mkdir|mkdirSync)\s*\(/, 'fs write'],
|
|
198
|
+
['toctou', /\b(existsSync|access|accessSync|statSync|lstatSync)\s*\(/, 'check-then-act candidate'],
|
|
199
|
+
['ssrf', /\b(fetch|axios(\.\w+)?|got|request|undici\.\w+|https?\.(get|request))\s*\(\s*(?!['"`]https?:)/, 'outbound request with computed URL'],
|
|
200
|
+
['ssrf', /new\s+URL\s*\([^)]*(req|params|query|body|config|opts|options)/i, 'URL built from external input'],
|
|
201
|
+
['proto-pollution', /__proto__|constructor\s*\.\s*prototype|\bprototype\s*\[/, 'prototype access'],
|
|
202
|
+
['proto-pollution', /\b(merge|deepMerge|extend|defaultsDeep|set|setWith)\s*\(|Object\.assign\s*\(\s*(\w+)\s*,\s*(req|body|params|payload|data|msg|input)/i, 'deep merge / assign of external object'],
|
|
203
|
+
['proto-pollution', /\w+\[\s*(key|k|prop|name|field|path\[\w+\])\s*\]\s*=/, 'computed-key assignment'],
|
|
204
|
+
['deserialization', /\b(v8\.deserialize|unserialize|yaml\.load\s*\((?![^)]*safe)|node-serialize|serialize-javascript|BSON\.deserialize|msgpack\.(decode|unpack))/i, 'unsafe deserializer'],
|
|
205
|
+
['deserialization', /JSON\.parse\s*\(/, 'JSON.parse (check try/catch, size, prototype keys)'],
|
|
206
|
+
['crypto', /createHash\s*\(\s*['"](md5|sha1)['"]|createCipher\s*\(|\bdes\b|\brc4\b|ecb/i, 'weak hash / cipher'],
|
|
207
|
+
['crypto', /Math\.random\s*\(/, 'Math.random (not for secrets/ids)'],
|
|
208
|
+
['crypto', /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED|strictSSL\s*:\s*false|checkServerIdentity\s*:\s*\(\s*\)\s*=>/, 'TLS verification disabled'],
|
|
209
|
+
['crypto', /\s===?\s*[^=]*(token|secret|signature|hmac|digest|password)\b|\b(token|secret|signature|hmac|digest|password)\s*===?\s/i, 'non-constant-time secret comparison'],
|
|
210
|
+
['insecure-defaults', /['"]0\.0\.0\.0['"]|host\s*:\s*['"]::['"]/, 'binds all interfaces'],
|
|
211
|
+
['insecure-defaults', /origin\s*:\s*(true|['"]\*['"])|Access-Control-Allow-Origin['"]?\s*[,:]\s*['"]\*/, 'permissive CORS'],
|
|
212
|
+
['insecure-defaults', /\b(auth|authentication|requireAuth|secure|verify|tls|ssl)\s*:\s*false\b|\bdebug\s*:\s*true\b/i, 'security toggle defaulting off / debug on'],
|
|
213
|
+
['insecure-defaults', /http:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|example)/, 'plain-http URL'],
|
|
214
|
+
['authz', /\b(skipAuth|noAuth|bypass|allowAll|whitelist\s*=\s*null|isAdmin\s*=\s*true|role\s*[:=]\s*['"]admin)/i, 'auth bypass flag'],
|
|
215
|
+
['authz', /\.(get|post|put|patch|delete|route|register|respond|handle|method|addMethod|on)\s*\(\s*['"`][^'"`]+['"`]\s*,\s*(async\s*)?\(?\s*\w*\s*(,\s*\w+)?\)?\s*=>/, 'route/RPC handler — confirm an auth check precedes it'],
|
|
216
|
+
['secret-leak', /(console\.(log|info|warn|error|debug)|\blog(ger)?\.(info|warn|error|debug|trace)|debug)\s*\([^)]*\b(token|secret|password|passwd|apiKey|api_key|privateKey|seed|mnemonic|authorization|cookie|credential)/i, 'secret-shaped identifier in a log call'],
|
|
217
|
+
['secret-leak', /\b(reply|res)\.(send|json)\s*\([^)]*\b(err|error)\b(\.stack)?|stack\s*:\s*err\.stack/, 'error object / stack returned to caller'],
|
|
218
|
+
['race', /setInterval\s*\(|\bawait\b.*\bfor\s*\(|Promise\.all\s*\(/, 'concurrency site (check shared-state mutation)'],
|
|
219
|
+
['input', /\bparseInt\s*\([^,)]*\)|Number\s*\(\s*(req|params|query|body)/, 'numeric parse of external input without radix/range'],
|
|
220
|
+
['input', /\bnew\s+RegExp\s*\(\s*(?!['"`])/, 'RegExp from dynamic input (ReDoS/injection)'],
|
|
221
|
+
// UI classes (references/vuln-classes.md U1–U9)
|
|
222
|
+
['xss', /dangerouslySetInnerHTML|\.(innerHTML|outerHTML)\s*=|insertAdjacentHTML\s*\(|document\.write\s*\(/, 'HTML sink (U1)'],
|
|
223
|
+
['xss', /rehype-raw|allowDangerousHtml\s*:\s*true/, 'raw HTML enabled in markdown rendering (U1)'],
|
|
224
|
+
['url', /window\.open\s*\(|location\.(href|assign|replace)\b\s*[=(]|\bwindow\.location\s*=/, 'navigation sink (U2)'],
|
|
225
|
+
['url', /target\s*=\s*['"{]?_blank(?![^>]*noopener)/, 'target=_blank without rel=noopener (U5)'],
|
|
226
|
+
['browser-storage', /\b(localStorage|sessionStorage)\.setItem\s*\(|createJSONStorage|persist\s*\(/, 'persisted browser state — tokens/fleet data? (U3/U9)'],
|
|
227
|
+
['browser-storage', /URLSearchParams\([^)]*location\.search|location\.search\b.*\b(token|auth)/i, 'value read from the URL — token adoption? (U3)'],
|
|
228
|
+
['postmessage', /addEventListener\s*\(\s*['"]message['"]|\.postMessage\s*\(/, 'postMessage — origin checked? (U5)']
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
const SECRET_RULES = SECRET_PATTERNS.map(([re, label]) => ({ cls: 'secrets', re, label }))
|
|
232
|
+
const SAST = SAST_RULES.map(([cls, re, label]) => ({ cls, re, label }))
|
|
233
|
+
const NOISY_CLASSES = new Set(['race', 'toctou', 'deserialization', 'fs-dangerous', 'authz', 'browser-storage'])
|
|
234
|
+
// Dev tools that must never be required peers: npm 7+ installs required peers for every consumer.
|
|
235
|
+
const DEV_TOOL_PEERS = /^(vitest|jest|@jest\/.*|@testing-library\/.*|vite|webpack|eslint.*|prettier|typescript|@types\/.*|ts-node|tsx|storybook|@storybook\/.*|playwright|@playwright\/.*|cypress)$/
|
|
236
|
+
|
|
237
|
+
function lineScan (text, rules) {
|
|
238
|
+
const hits = []
|
|
239
|
+
const lines = text.split('\n')
|
|
240
|
+
for (let i = 0; i < lines.length; i++) {
|
|
241
|
+
const l = lines[i]
|
|
242
|
+
if (l.length > 2000) continue // minified / data
|
|
243
|
+
const trimmed = l.trim()
|
|
244
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue
|
|
245
|
+
for (const { cls, re, label } of rules) {
|
|
246
|
+
if (re.test(l)) hits.push({ line: i + 1, cls, label, text: trimmed.slice(0, 160) })
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return hits
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function importsOf (text) {
|
|
253
|
+
const out = []
|
|
254
|
+
const re = /(?:require\s*\(\s*|import\s+(?:[\w*{}\s,]+\s+from\s+)?|import\s*\(\s*|export\s+[\w*{}\s,]+\s+from\s+)['"]([^'"]+)['"]/g
|
|
255
|
+
let m
|
|
256
|
+
while ((m = re.exec(text))) out.push({ spec: m[1], line: text.slice(0, m.index).split('\n').length })
|
|
257
|
+
return out
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function bareName (spec) {
|
|
261
|
+
if (spec.startsWith('@')) return spec.split('/').slice(0, 2).join('/')
|
|
262
|
+
return spec.split('/')[0]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function scanPackage (dir) {
|
|
266
|
+
const pj = readJson(path.join(dir, 'package.json'))
|
|
267
|
+
const rel = path.relative(opt.root, dir) || '.'
|
|
268
|
+
const r = { name: pj.name, version: pj.version, path: rel, findings: [], leads: [], tarball: null, audit: null }
|
|
269
|
+
const add = (severity, check, msg, where) => r.findings.push({ severity, check, msg, where })
|
|
270
|
+
|
|
271
|
+
// packaging metadata
|
|
272
|
+
if (!Array.isArray(pj.files)) add('high', 'files-allowlist', 'no "files" allowlist; tarball ships everything not .npmignore/.gitignore-d')
|
|
273
|
+
if (pj.name.startsWith('@') && pj.publishConfig?.access !== 'public') add('medium', 'publish-access', 'scoped package without publishConfig.access — first publish defaults to restricted')
|
|
274
|
+
if (!pj.repository) add('medium', 'repository', 'no "repository" field — npm --provenance needs it')
|
|
275
|
+
if (!pj.license) add('medium', 'license', 'no "license" field')
|
|
276
|
+
const scripts = pj.scripts || {}
|
|
277
|
+
for (const s of ['preinstall', 'install', 'postinstall', 'prepare']) {
|
|
278
|
+
if (scripts[s]) add(s === 'prepare' ? 'low' : 'high', 'install-scripts', `"${s}" script runs on consumers' machines: ${scripts[s]}`)
|
|
279
|
+
}
|
|
280
|
+
const entryPoints = [pj.main, ...Object.values(pj.bin || {}), ...(typeof pj.bin === 'string' ? [pj.bin] : [])].filter(Boolean)
|
|
281
|
+
for (const e of entryPoints) {
|
|
282
|
+
if (/(^|\/)(dist|build|lib-out)\//.test(e) && !fs.existsSync(path.join(dir, e)) && !scripts.prepack && !scripts.prepublishOnly) {
|
|
283
|
+
add('high', 'prepack-build', `entry ${e} is a build output, absent from the tree, and no prepack/prepublishOnly builds it`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const declared = { ...pj.dependencies, ...pj.peerDependencies, ...pj.optionalDependencies }
|
|
287
|
+
const rootOverrides = readJson(path.join(opt.root, 'package.json'))?.overrides || {}
|
|
288
|
+
for (const [d, v] of Object.entries({ ...declared })) {
|
|
289
|
+
const spec = String(v).trim()
|
|
290
|
+
if (/^(file:|link:|workspace:|git\+|github:|https?:)/.test(spec)) add('high', 'dep-source', `dependency ${d} uses non-registry spec "${v}" — breaks or is unpinned for consumers`)
|
|
291
|
+
else if (/^(\*|x|latest|next|)$/i.test(spec) || /^>=?[^<|]*$/.test(spec)) {
|
|
292
|
+
add(d.startsWith(pj.name.split('/')[0] + '/') ? 'high' : 'medium', 'loose-range', `dependency ${d} uses unbounded range "${spec}" — accepts any future major or a hijacked release; pin to ^x.y.z`)
|
|
293
|
+
}
|
|
294
|
+
const ws = WORKSPACE.get(d)
|
|
295
|
+
if (ws?.private) add('high', 'private-dep', `depends on ${d}, which is "private": true in this repo — 404 for consumers`)
|
|
296
|
+
// Fine when the package's own range already carries the override's floor (^4.12.34 vs >=4.12.34).
|
|
297
|
+
const floor = (v) => String(v).match(/\d+\.\d+\.\d+/)?.[0]
|
|
298
|
+
const sameFloor = typeof rootOverrides[d] === 'string' && floor(rootOverrides[d]) && floor(rootOverrides[d]) === floor(spec)
|
|
299
|
+
if (rootOverrides[d] !== undefined && dir !== opt.root && !sameFloor) add('medium', 'root-override', `root package.json overrides ${d} → ${JSON.stringify(rootOverrides[d])}; consumers do not get root overrides — raise the range here`)
|
|
300
|
+
}
|
|
301
|
+
const optionalPeers = pj.peerDependenciesMeta || {}
|
|
302
|
+
for (const d of Object.keys(pj.peerDependencies || {})) {
|
|
303
|
+
if (DEV_TOOL_PEERS.test(d) && optionalPeers[d]?.optional !== true) {
|
|
304
|
+
add('medium', 'dev-peer', `dev tool "${d}" is a required peerDependency — npm 7+ installs it into every consumer; drop it or mark it optional`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (pj.overrides && dir !== opt.root) add('low', 'package-overrides', `"overrides" in a published package is ignored for consumers: ${Object.keys(pj.overrides).join(', ')}`)
|
|
308
|
+
|
|
309
|
+
// tarball contents
|
|
310
|
+
const tb = tarballFiles(dir)
|
|
311
|
+
r.tarball = { source: tb.source, count: tb.files.length, size: tb.size }
|
|
312
|
+
|
|
313
|
+
// entry points must be in the tarball (a prepack build may create them — confirm with --smoke)
|
|
314
|
+
const shipped = new Set(tb.files.map(norm))
|
|
315
|
+
const inTarball = (t) => [norm(t), norm(t) + '.js', norm(t) + '.json', path.posix.join(norm(t), 'index.js')].some((c) => shipped.has(c))
|
|
316
|
+
const built = scripts.prepack || scripts.prepublishOnly
|
|
317
|
+
const targets = [
|
|
318
|
+
...(pj.main ? [['main', pj.main]] : []),
|
|
319
|
+
...exportTargets(pj.exports).map((t) => ['exports', t]),
|
|
320
|
+
...binEntries(pj).map(([n, t]) => [`bin ${n}`, t])
|
|
321
|
+
]
|
|
322
|
+
for (const [kind, t] of targets) {
|
|
323
|
+
if (!inTarball(t)) add(built ? 'low' : 'high', 'entry-missing', `${kind} target ${t} is not in the tarball${built ? ' (prepack may build it — confirm with --smoke)' : ''}`)
|
|
324
|
+
}
|
|
325
|
+
for (const [n, t] of binEntries(pj)) {
|
|
326
|
+
const f = path.join(dir, t)
|
|
327
|
+
if (fs.existsSync(f) && !fs.readFileSync(f, 'utf8').startsWith('#!')) add('high', 'bin-shebang', `bin ${n} (${t}) has no #! line — fails when run as a command`)
|
|
328
|
+
}
|
|
329
|
+
if (pj.main && !pj.exports) add('low', 'exports-map', 'no "exports" map — consumers can deep-require any shipped file, so internals become public API')
|
|
330
|
+
const smells = {}
|
|
331
|
+
for (const f of tb.files) for (const [re, why] of SHIP_SMELLS) if (re.test(f)) (smells[why] ||= []).push(f)
|
|
332
|
+
for (const [why, files] of Object.entries(smells)) {
|
|
333
|
+
const sev = /key|\.env|credentials|real config|database/.test(why) ? 'high' : 'medium'
|
|
334
|
+
add(sev, 'tarball-contents', `tarball ships ${why}: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` (+${files.length - 5} more)` : ''}`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// per-file: secrets, imports, sast
|
|
338
|
+
const undeclared = new Map()
|
|
339
|
+
for (const f of tb.files) {
|
|
340
|
+
const abs = path.join(dir, f)
|
|
341
|
+
let text
|
|
342
|
+
try {
|
|
343
|
+
const st = fs.statSync(abs)
|
|
344
|
+
if (st.size > 2 << 20) continue
|
|
345
|
+
text = fs.readFileSync(abs, 'utf8')
|
|
346
|
+
} catch { continue }
|
|
347
|
+
if (text.includes('\u0000')) continue
|
|
348
|
+
|
|
349
|
+
const isTestish = /(^|\/)(tests?|__tests__|mocks?|fixtures?)\//.test(f)
|
|
350
|
+
for (const h of lineScan(text, SECRET_RULES)) {
|
|
351
|
+
if (/example|placeholder|xxx|changeme|<[^>]+>|\$\{|process\.env/i.test(h.text)) continue
|
|
352
|
+
const network = /private IP|internal hostname/.test(h.label)
|
|
353
|
+
if (network && isTestish) continue
|
|
354
|
+
const sev = network || isTestish ? 'low' : 'high'
|
|
355
|
+
add(sev, 'secrets', `${h.label}: ${h.text}`, `${f}:${h.line}`)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!CODE_EXT.test(f) || /\.d\.ts$/.test(f)) continue
|
|
359
|
+
for (const { spec, line } of importsOf(text)) {
|
|
360
|
+
if (spec.startsWith('.')) {
|
|
361
|
+
const target = path.resolve(path.dirname(abs), spec)
|
|
362
|
+
if (!target.startsWith(dir + path.sep) && target !== dir) {
|
|
363
|
+
add('high', 'escaping-import', `relative import "${spec}" resolves outside the package (${path.relative(opt.root, target)}) — missing after npm install`, `${f}:${line}`)
|
|
364
|
+
}
|
|
365
|
+
} else if (!BUILTINS.has(spec) && !spec.startsWith('node:') && !/^[#$~]/.test(spec)) {
|
|
366
|
+
const n = bareName(spec)
|
|
367
|
+
if (n !== pj.name && !declared[n] && !/(^|\/)(tests?|mocks?|fixtures?|scripts?|templates?|assets)\//.test(f)) {
|
|
368
|
+
if (!undeclared.has(n)) undeclared.set(n, `${f}:${line}`)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Leads only in shipped runtime code; tests/mocks are triaged via tarball-contents.
|
|
373
|
+
if (isTestish || /(^|\/)(templates?|assets)\//.test(f) || path.basename(f) === SELF) continue
|
|
374
|
+
for (const h of lineScan(text, SAST)) r.leads.push({ ...h, where: `${f}:${h.line}` })
|
|
375
|
+
}
|
|
376
|
+
for (const [n, where] of undeclared) {
|
|
377
|
+
const dev = pj.devDependencies?.[n]
|
|
378
|
+
add('high', 'undeclared-dep', `imports "${n}" but it is ${dev ? 'only a devDependency' : 'not declared in dependencies'}`, where)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// npm audit (network)
|
|
382
|
+
if (opt.audit) {
|
|
383
|
+
// Audit from the workspace that owns the package (ui/ has its own lockfile).
|
|
384
|
+
let wsRoot = dir
|
|
385
|
+
while (wsRoot !== opt.root && wsRoot !== path.dirname(wsRoot) && !fs.existsSync(path.join(wsRoot, 'package-lock.json'))) wsRoot = path.dirname(wsRoot)
|
|
386
|
+
const auditArgs = ['audit', '--omit=dev', '--json', ...(wsRoot === dir ? [] : [`--workspace=${pj.name}`])]
|
|
387
|
+
const a = run('npm', auditArgs, wsRoot)
|
|
388
|
+
try {
|
|
389
|
+
const j = JSON.parse(a.out || '{}')
|
|
390
|
+
r.audit = j.metadata?.vulnerabilities || j.error || null
|
|
391
|
+
const v = j.metadata?.vulnerabilities || {}
|
|
392
|
+
if ((v.critical || 0) + (v.high || 0) > 0) add('high', 'npm-audit', `npm audit: ${v.critical || 0} critical, ${v.high || 0} high, ${v.moderate || 0} moderate (prod deps)`)
|
|
393
|
+
else if (v.moderate) add('medium', 'npm-audit', `npm audit: ${v.moderate} moderate (prod deps)`)
|
|
394
|
+
} catch { r.audit = { error: (a.err || 'unparseable npm audit output').slice(0, 300) } }
|
|
395
|
+
}
|
|
396
|
+
return r
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Pack every package, install the tarballs together in a scratch project outside the
|
|
400
|
+
// repo (so escaping ../.. paths cannot resolve), then import each entry and run each bin.
|
|
401
|
+
// npm pack runs the package's own prepack/prepare (our build); install uses --ignore-scripts.
|
|
402
|
+
const MISSING_MODULE = /Cannot find (module|package)|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|ERR_PACKAGE_PATH_NOT_EXPORTED/
|
|
403
|
+
|
|
404
|
+
function smoke (dirs, results) {
|
|
405
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mdk-smoke-'))
|
|
406
|
+
const tarDir = path.join(tmp, 'tarballs')
|
|
407
|
+
const proj = path.join(tmp, 'project')
|
|
408
|
+
fs.mkdirSync(tarDir)
|
|
409
|
+
fs.mkdirSync(proj)
|
|
410
|
+
const summary = { dir: opt.keepSmoke ? tmp : null, install: null, audit: null }
|
|
411
|
+
const tarballs = []
|
|
412
|
+
dirs.forEach((dir, i) => {
|
|
413
|
+
// prepack output can precede the JSON on stdout, so find the new tarball on disk instead.
|
|
414
|
+
const before = new Set(fs.readdirSync(tarDir))
|
|
415
|
+
const p = run('npm', ['pack', '--pack-destination', tarDir], dir, { timeout: 300000 })
|
|
416
|
+
const made = fs.readdirSync(tarDir).find((f) => !before.has(f) && f.endsWith('.tgz'))
|
|
417
|
+
if (made) tarballs.push(path.join(tarDir, made))
|
|
418
|
+
else results[i].findings.push({ severity: 'high', check: 'smoke-pack', msg: `npm pack failed: ${firstError(p.err || p.out)}` })
|
|
419
|
+
})
|
|
420
|
+
fs.writeFileSync(path.join(proj, 'package.json'), JSON.stringify({ name: 'mdk-publish-smoke', version: '0.0.0', private: true }))
|
|
421
|
+
const inst = run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', ...tarballs], proj, { timeout: 600000 })
|
|
422
|
+
summary.install = inst.ok ? `ok (${tarballs.length} tarballs)` : `failed: ${firstError(inst.err)}`
|
|
423
|
+
if (!inst.ok) {
|
|
424
|
+
for (const r of results) r.findings.push({ severity: 'high', check: 'smoke-install', msg: `installing the packed tarballs together failed: ${firstError(inst.err)}` })
|
|
425
|
+
} else {
|
|
426
|
+
dirs.forEach((dir, i) => {
|
|
427
|
+
const pj = readJson(path.join(dir, 'package.json'))
|
|
428
|
+
const add = (severity, check, msg) => results[i].findings.push({ severity, check, msg })
|
|
429
|
+
for (const spec of entrySpecs(pj)) {
|
|
430
|
+
const r = run(process.execPath, ['--input-type=module', '-e', 'await import(process.env.SMOKE_SPEC); process.exit(0)'], proj, { timeout: 30000, env: { SMOKE_SPEC: spec } })
|
|
431
|
+
if (r.timedOut) add('medium', 'smoke-import', `import("${spec}") did not return within 30s — side effects at load time`)
|
|
432
|
+
else if (!r.ok && MISSING_MODULE.test(r.err)) add('high', 'smoke-import', `import("${spec}") fails after install: ${firstError(r.err)}`)
|
|
433
|
+
else if (!r.ok) add('low', 'smoke-import', `import("${spec}") throws at load time (confirm it is intentional): ${firstError(r.err)}`)
|
|
434
|
+
}
|
|
435
|
+
for (const [n, t] of binEntries(pj)) {
|
|
436
|
+
const f = path.join(proj, 'node_modules', pj.name, t)
|
|
437
|
+
if (!fs.existsSync(f)) { add('high', 'smoke-bin', `bin ${n} → ${t} is missing from the installed package`); continue }
|
|
438
|
+
const r = run(process.execPath, [f, '--help'], proj, { timeout: 30000 })
|
|
439
|
+
if (r.timedOut) add('medium', 'smoke-bin', `bin ${n} --help did not exit within 30s`)
|
|
440
|
+
else if (!r.ok && MISSING_MODULE.test(r.err)) add('high', 'smoke-bin', `bin ${n} --help fails: ${firstError(r.err)}`)
|
|
441
|
+
else if (!r.ok) add('low', 'smoke-bin', `bin ${n} --help exits non-zero: ${firstError(r.err)}`)
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
// Consumer-view audit: no root overrides, real resolved ranges.
|
|
445
|
+
const a = run('npm', ['audit', '--omit=dev', '--json'], proj, { timeout: 300000 })
|
|
446
|
+
try {
|
|
447
|
+
const j = JSON.parse(a.out || '{}')
|
|
448
|
+
summary.audit = j.metadata?.vulnerabilities || firstError(a.err)
|
|
449
|
+
summary.vulnerable = Object.values(j.vulnerabilities || {})
|
|
450
|
+
.filter((v) => ['moderate', 'high', 'critical'].includes(v.severity))
|
|
451
|
+
.map((v) => `${v.name} (${v.severity}${v.fixAvailable ? '' : ', no fix'})`)
|
|
452
|
+
} catch { summary.audit = firstError(a.err || 'unparseable npm audit output') }
|
|
453
|
+
}
|
|
454
|
+
if (!opt.keepSmoke) fs.rmSync(tmp, { recursive: true, force: true })
|
|
455
|
+
return summary
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Registry state per name: unclaimed (confirm scope ownership before docs tell people
|
|
459
|
+
// to install it), version already taken, or published from a different repository.
|
|
460
|
+
function registry (dirs, results) {
|
|
461
|
+
const who = run('npm', ['whoami'], opt.root, { timeout: 30000 })
|
|
462
|
+
const summary = { whoami: who.ok ? who.out.trim() : `not logged in (${firstError(who.err)})`, scopes: {} }
|
|
463
|
+
const localRepo = readJson(path.join(opt.root, 'package.json'))?.repository
|
|
464
|
+
const repoUrl = (r) => String(r?.url || r || '').replace(/^git\+/, '').replace(/\.git$/, '').toLowerCase()
|
|
465
|
+
dirs.forEach((dir, i) => {
|
|
466
|
+
const pj = readJson(path.join(dir, 'package.json'))
|
|
467
|
+
const add = (severity, check, msg) => results[i].findings.push({ severity, check, msg })
|
|
468
|
+
const scope = pj.name.startsWith('@') ? pj.name.split('/')[0] : null
|
|
469
|
+
if (scope && !(scope in summary.scopes)) {
|
|
470
|
+
const org = run('npm', ['access', 'list', 'packages', scope, '--json'], opt.root, { timeout: 30000 })
|
|
471
|
+
let n = null
|
|
472
|
+
try { n = Object.keys(JSON.parse(org.out || '{}')).length } catch {}
|
|
473
|
+
summary.scopes[scope] = n !== null
|
|
474
|
+
? `${n} package(s) already published under ${scope} — the scope is claimed; confirm with \`npm org ls ${scope.slice(1)}\` (logged in) that you are a member with publish rights`
|
|
475
|
+
: `cannot list (${firstError(org.err)}) — the scope may be unclaimed; create the org before anyone else does`
|
|
476
|
+
}
|
|
477
|
+
const v = run('npm', ['view', pj.name, '--json'], opt.root, { timeout: 30000 })
|
|
478
|
+
if (!v.ok && /E404/.test(v.err)) { add('low', 'registry-unclaimed', `${pj.name} is not on the registry — confirm you own ${scope || 'the name'} before docs tell users to install it`); return }
|
|
479
|
+
if (!v.ok) { add('low', 'registry-unclaimed', `npm view failed: ${firstError(v.err)}`); return }
|
|
480
|
+
let info
|
|
481
|
+
try { info = JSON.parse(v.out) } catch { return }
|
|
482
|
+
if ([].concat(info.versions || []).includes(pj.version)) add('high', 'registry-version', `${pj.name}@${pj.version} is already published — bump before publishing`)
|
|
483
|
+
const theirs = repoUrl(info.repository)
|
|
484
|
+
const ours = repoUrl(pj.repository || localRepo)
|
|
485
|
+
if (theirs && ours && theirs !== ours) add('high', 'registry-owner', `registry ${pj.name} points at ${theirs}, not ${ours} — possible name squat; check maintainers: ${[].concat(info.maintainers || []).join(', ')}`)
|
|
486
|
+
})
|
|
487
|
+
return summary
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function toMarkdown (results, repo) {
|
|
491
|
+
const sevOrder = { high: 0, medium: 1, low: 2 }
|
|
492
|
+
const out = ['# Pre-publish security scan', '', `Root: \`${opt.root}\` · packages: ${results.length} · ${new Date().toISOString()}`, '']
|
|
493
|
+
if (repo.smoke) {
|
|
494
|
+
out.push(`Smoke install: ${repo.smoke.install}${repo.smoke.dir ? ` (kept at \`${repo.smoke.dir}\`)` : ''}`, '')
|
|
495
|
+
out.push(`Consumer-view npm audit (fresh install, no root overrides): \`${JSON.stringify(repo.smoke.audit)}\``, '')
|
|
496
|
+
if (repo.smoke.vulnerable?.length) out.push(`Vulnerable: ${repo.smoke.vulnerable.join(', ')} — trace with \`npm ls <name>\` in the kept smoke project (\`--keep-smoke\`)`, '')
|
|
497
|
+
}
|
|
498
|
+
if (repo.registry) {
|
|
499
|
+
out.push(`npm whoami: ${repo.registry.whoami}`, '')
|
|
500
|
+
for (const [s, v] of Object.entries(repo.registry.scopes)) out.push(`Scope \`${s}\`: ${v}`, '')
|
|
501
|
+
}
|
|
502
|
+
out.push('| Package | Tarball files | High | Medium | Low | SAST leads |', '| --- | --- | --- | --- | --- | --- |')
|
|
503
|
+
for (const r of results) {
|
|
504
|
+
const c = (s) => r.findings.filter((f) => f.severity === s).length
|
|
505
|
+
out.push(`| \`${r.name}\` | ${r.tarball.count} | ${c('high')} | ${c('medium')} | ${c('low')} | ${r.leads.length} |`)
|
|
506
|
+
}
|
|
507
|
+
for (const r of results) {
|
|
508
|
+
out.push('', `## \`${r.name}\` (${r.path})`, '')
|
|
509
|
+
if (r.audit) out.push(`npm audit: \`${JSON.stringify(r.audit)}\``, '')
|
|
510
|
+
const fs_ = [...r.findings].sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity])
|
|
511
|
+
if (!fs_.length) out.push('No mechanical findings.')
|
|
512
|
+
for (const f of fs_) out.push(`- **${f.severity}** \`${f.check}\` ${f.msg}${f.where ? ` — \`${f.where}\`` : ''}`)
|
|
513
|
+
const byCls = {}
|
|
514
|
+
for (const l of r.leads) (byCls[l.cls] ||= []).push(l)
|
|
515
|
+
if (Object.keys(byCls).length) {
|
|
516
|
+
out.push('', '<details><summary>SAST leads (triage manually)</summary>', '')
|
|
517
|
+
for (const [cls, ls] of Object.entries(byCls)) {
|
|
518
|
+
const shown = NOISY_CLASSES.has(cls) ? ls.slice(0, 15) : ls.slice(0, 40)
|
|
519
|
+
out.push(`- **${cls}** (${ls.length})`)
|
|
520
|
+
for (const l of shown) out.push(` - \`${l.where}\` ${l.label}: \`${l.text.replace(/`/g, "'")}\``)
|
|
521
|
+
if (shown.length < ls.length) out.push(` - … ${ls.length - shown.length} more`)
|
|
522
|
+
}
|
|
523
|
+
out.push('', '</details>')
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return out.join('\n') + '\n'
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
const dirs = discover()
|
|
531
|
+
if (!dirs.length) throw new Error('no publishable packages found')
|
|
532
|
+
const results = dirs.map(scanPackage)
|
|
533
|
+
const repo = {}
|
|
534
|
+
if (opt.smoke) repo.smoke = smoke(dirs, results)
|
|
535
|
+
if (opt.registry) repo.registry = registry(dirs, results)
|
|
536
|
+
const body = opt.json ? JSON.stringify({ repo, packages: results }, null, 2) : toMarkdown(results, repo)
|
|
537
|
+
if (opt.out) fs.writeFileSync(opt.out, body, { flag: 'wx', mode: 0o600 })
|
|
538
|
+
else process.stdout.write(body)
|
|
539
|
+
const highs = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === 'high').length, 0)
|
|
540
|
+
console.error(`scanned ${results.length} package(s): ${highs} high-severity mechanical finding(s)`)
|
|
541
|
+
} catch (err) {
|
|
542
|
+
console.error(`ERR_SCAN: ${err instanceof Error ? err.message : String(err)}`)
|
|
543
|
+
process.exit(1)
|
|
544
|
+
}
|