Skip to content

Commit 725bff5

Browse files
committed
feat(audit): Socket.dev malware scan on val deploy + CDN scripts
- Pin all `npm:hono@4` specifiers across val/*.ts to `npm:hono@4.12.14`. Prevents silent upgrades when Val Town regenerates its internal lockfile on resave. Belt-and-suspenders: even without pinning, Val Town locks on save, but pinning kills drift at the source. - Add scripts/audit-deps.mts with two entry points: - auditValDeps(): extracts npm: specifiers from val/*.ts, walks the transitive closure locally via pacote (matching Deno's resolution), batches PURLs through sdk.checkMalware() using SOCKET_PUBLIC_API_TOKEN. Fails on type:'malware' or severity:'critical'. Runs before deploy-val uploads anything. - auditCdnScripts(): extracts <script src="https://unpkg.com/..."> refs from the generated walkthrough HTML (marked, highlight.js), scans them through the same API. Runs at the end of `generate`, before minification. - Both audits respect SKIP_AUDIT=1 for offline dev; CI never sets it. - Add @socketsecurity/sdk@4.0.1 as devDep. SDK pattern mirrors the socket-sdk-js check-new-deps hook — same PURL + batch + normalize approach.
1 parent 73b6d27 commit 725bff5

10 files changed

Lines changed: 307 additions & 10 deletions

File tree

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"@oxlint/migrate": "1.51.0",
7070
"@socketsecurity/lib": "5.21.0",
7171
"@socketsecurity/registry": "2.0.2",
72+
"@socketsecurity/sdk": "4.0.1",
7273
"@types/node": "24.9.2",
7374
"@typescript/native-preview": "7.0.0-dev.20260415.1",
7475
"@vitest/coverage-v8": "4.0.3",
@@ -96,9 +97,11 @@
9697
"yoctocolors-cjs": "2.1.3"
9798
},
9899
"socket": {
99-
"categories": [
100-
"levelup"
101-
]
100+
"val": {
101+
"npm": {
102+
"hono": "4.12.14"
103+
}
104+
}
102105
},
103106
"typeCoverage": {
104107
"cache": true,

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/audit-deps.mts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/**
2+
* @fileoverview Socket.dev malware audit for the walkthrough pilot.
3+
*
4+
* Two entry points: `auditValDeps()` runs before the Val Town deploy
5+
* (scans the transitive closure of `npm:` specifiers imported by our
6+
* val files), and `auditCdnScripts()` runs after HTML generation
7+
* (scans the third-party scripts meander emits `<script src=>` tags for
8+
* — marked, highlight.js). Both fail-closed: a malware alert aborts
9+
* the deploy / generate step.
10+
*
11+
* Closure resolution is done locally via `pacote.manifest()` so we
12+
* don't depend on Val Town exposing its own deno.lock via API (it
13+
* doesn't). Same npm registry + semver resolution Deno uses at
14+
* runtime, so the local pacote tree matches what the val will load.
15+
*
16+
* API uses the built-in `SOCKET_PUBLIC_API_TOKEN` — no user secret
17+
* required, same pattern as socket-sdk-js's `check-new-deps` hook.
18+
*/
19+
20+
import { readdirSync, readFileSync } from 'node:fs'
21+
import path from 'node:path'
22+
23+
import { SOCKET_PUBLIC_API_TOKEN } from '@socketsecurity/lib/constants/socket'
24+
import { SocketSdk } from '@socketsecurity/sdk'
25+
import type { MalwareCheckPackage } from '@socketsecurity/sdk'
26+
import pacote from 'pacote'
27+
import semver from 'semver'
28+
29+
// Matches `npm:<scope?>/<name>@<version>(/subpath)?` — the Deno npm
30+
// specifier shape. `version` may be a semver range (we normalize it
31+
// to the resolved exact version via pacote). Scope is optional but
32+
// if present starts with `@`.
33+
const NPM_SPECIFIER_RE =
34+
/npm:(?<spec>(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*@[a-z0-9.+~-]+)(?:\/|['"])/gi
35+
36+
// Matches `<script src="https://unpkg.com/<scope?>/<name>@<version>/...`.
37+
// unpkg is the convention meander uses; extend the regex if we ever
38+
// add a CDN.
39+
const UNPKG_SCRIPT_RE =
40+
/<script\s+src="https:\/\/unpkg\.com\/(?<spec>(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*@[a-z0-9.+~-]+)\//gi
41+
42+
const API_TIMEOUT_MS = 10_000
43+
// Socket API caps batch at 1024; we stay well under.
44+
const MAX_BATCH_SIZE = 500
45+
46+
type NpmDep = {
47+
name: string
48+
version: string
49+
source: 'direct' | 'transitive' | 'cdn'
50+
}
51+
52+
const sdk = new SocketSdk(SOCKET_PUBLIC_API_TOKEN, { timeout: API_TIMEOUT_MS })
53+
54+
// --- specifier extraction ---
55+
56+
/**
57+
* Parse `npm:` specifiers out of every `.ts` file in `dir`. Deduped
58+
* by `name@version` string. Only direct deps — transitives come from
59+
* the pacote walk.
60+
*/
61+
function extractNpmDepsFromDir(dir: string): NpmDep[] {
62+
const seen = new Map<string, NpmDep>()
63+
for (const entry of readdirSync(dir)) {
64+
if (!entry.endsWith('.ts')) {
65+
continue
66+
}
67+
const source = readFileSync(path.join(dir, entry), 'utf8')
68+
for (const m of source.matchAll(NPM_SPECIFIER_RE)) {
69+
const spec = m.groups!['spec']!
70+
const atIndex = spec.lastIndexOf('@')
71+
const name = spec.slice(0, atIndex)
72+
const version = spec.slice(atIndex + 1)
73+
const key = `${name}@${version}`
74+
if (!seen.has(key)) {
75+
seen.set(key, { name, version, source: 'direct' })
76+
}
77+
}
78+
}
79+
return [...seen.values()]
80+
}
81+
82+
/**
83+
* Extract unpkg script deps from generated HTML files.
84+
*/
85+
function extractCdnDepsFromDir(dir: string): NpmDep[] {
86+
const seen = new Map<string, NpmDep>()
87+
for (const entry of readdirSync(dir)) {
88+
if (!entry.endsWith('.html')) {
89+
continue
90+
}
91+
const source = readFileSync(path.join(dir, entry), 'utf8')
92+
for (const m of source.matchAll(UNPKG_SCRIPT_RE)) {
93+
const spec = m.groups!['spec']!
94+
const atIndex = spec.lastIndexOf('@')
95+
const name = spec.slice(0, atIndex)
96+
const version = spec.slice(atIndex + 1)
97+
const key = `${name}@${version}`
98+
if (!seen.has(key)) {
99+
seen.set(key, { name, version, source: 'cdn' })
100+
}
101+
}
102+
}
103+
return [...seen.values()]
104+
}
105+
106+
// --- transitive closure via pacote ---
107+
108+
/**
109+
* Resolve each direct dep's transitive closure via pacote and return
110+
* a flat deduped list. Versions are resolved to exact (pacote returns
111+
* the manifest for the resolved version even when the spec is a range).
112+
*
113+
* We don't attempt to reach devDependencies — only runtime deps are
114+
* what Deno will actually fetch. Peer deps are optional and usually
115+
* satisfied by the consumer; skip unless they land in the graph by
116+
* being declared as regular dependencies elsewhere.
117+
*/
118+
async function walkTransitiveClosure(
119+
directs: readonly NpmDep[],
120+
): Promise<NpmDep[]> {
121+
const closure = new Map<string, NpmDep>()
122+
const queue: Array<{ spec: string; source: NpmDep['source'] }> = directs.map(
123+
d => ({ spec: `${d.name}@${d.version}`, source: 'direct' }),
124+
)
125+
126+
while (queue.length > 0) {
127+
const { spec, source } = queue.shift()!
128+
// Skip if we've already processed this resolved pair.
129+
// Two different ranges may resolve to the same exact version;
130+
// dedupe on the post-resolution pair.
131+
let manifest
132+
try {
133+
manifest = await pacote.manifest(spec, { fullMetadata: false })
134+
} catch {
135+
// Network error, package not found, etc. Skip — the deploy will
136+
// fail on the API call anyway if Socket can't score it.
137+
continue
138+
}
139+
const key = `${manifest.name}@${manifest.version}`
140+
if (closure.has(key)) {
141+
continue
142+
}
143+
closure.set(key, {
144+
name: manifest.name,
145+
version: manifest.version,
146+
source,
147+
})
148+
// Enqueue declared runtime deps as transitives.
149+
const deps = (manifest.dependencies ?? {}) as Record<string, string>
150+
for (const [depName, depRange] of Object.entries(deps)) {
151+
// pacote expects `<name>@<spec>`; `depRange` may be `^1.0.0`,
152+
// `~2.3`, a tag like `latest`, or a git URL. pacote handles all.
153+
if (!semver.validRange(depRange) && !depRange.includes(':')) {
154+
// Tag or odd spec — let pacote attempt resolution anyway.
155+
}
156+
queue.push({ spec: `${depName}@${depRange}`, source: 'transitive' })
157+
}
158+
}
159+
return [...closure.values()]
160+
}
161+
162+
// --- Socket.dev malware check ---
163+
164+
type AuditFinding = {
165+
dep: NpmDep
166+
alerts: Array<{ type: string; severity: string | undefined }>
167+
}
168+
169+
async function checkMalwareBatched(
170+
deps: readonly NpmDep[],
171+
): Promise<AuditFinding[]> {
172+
const findings: AuditFinding[] = []
173+
for (let i = 0; i < deps.length; i += MAX_BATCH_SIZE) {
174+
const batch = deps.slice(i, i + MAX_BATCH_SIZE)
175+
const components = batch.map(d => ({
176+
purl: `pkg:npm/${d.name}@${d.version}`,
177+
}))
178+
const result = await sdk.checkMalware(components)
179+
if (!result.success) {
180+
throw new Error(
181+
`Socket API returned status ${result.status} — cannot audit, failing closed.`,
182+
)
183+
}
184+
// Index returned packages by name@version for lookup.
185+
const byKey = new Map<string, MalwareCheckPackage>()
186+
for (const pkg of result.data) {
187+
const key = `${pkg.name ?? ''}@${pkg.version ?? ''}`
188+
byKey.set(key, pkg)
189+
}
190+
for (const dep of batch) {
191+
const pkg = byKey.get(`${dep.name}@${dep.version}`)
192+
if (!pkg?.alerts?.length) {
193+
continue
194+
}
195+
// Fail on malware type OR critical severity of any kind.
196+
const blocking = pkg.alerts.filter(
197+
a => a.type === 'malware' || a.severity === 'critical',
198+
)
199+
if (blocking.length > 0) {
200+
findings.push({
201+
dep,
202+
alerts: blocking.map(a => ({ type: a.type, severity: a.severity })),
203+
})
204+
}
205+
}
206+
}
207+
return findings
208+
}
209+
210+
// --- public entry points ---
211+
212+
/**
213+
* Audit the val's transitive npm dep closure. Throws on finding,
214+
* caller aborts the deploy.
215+
*/
216+
export async function auditValDeps(repoRoot: string): Promise<void> {
217+
const valDir = path.join(repoRoot, 'val')
218+
const directs = extractNpmDepsFromDir(valDir)
219+
if (directs.length === 0) {
220+
console.log('[audit-deps] no npm specifiers found in val/')
221+
return
222+
}
223+
console.log(
224+
`[audit-deps] val direct deps: ${directs.map(d => `${d.name}@${d.version}`).join(', ')}`,
225+
)
226+
const closure = await walkTransitiveClosure(directs)
227+
console.log(
228+
`[audit-deps] resolved closure: ${closure.length} package${closure.length === 1 ? '' : 's'} (${closure.filter(c => c.source === 'direct').length} direct, ${closure.filter(c => c.source === 'transitive').length} transitive)`,
229+
)
230+
const findings = await checkMalwareBatched(closure)
231+
reportAndThrow(findings, 'val deployment')
232+
}
233+
234+
/**
235+
* Audit the CDN scripts the generated walkthrough HTML loads via
236+
* `<script src=https://unpkg.com/...>`. No transitive walk — CDN
237+
* bundles are preflight-built, their deps don't ship separately.
238+
*/
239+
export async function auditCdnScripts(walkthroughDir: string): Promise<void> {
240+
const cdnDeps = extractCdnDepsFromDir(walkthroughDir)
241+
if (cdnDeps.length === 0) {
242+
console.log('[audit-deps] no unpkg CDN scripts in walkthrough HTML')
243+
return
244+
}
245+
console.log(
246+
`[audit-deps] CDN scripts: ${cdnDeps.map(d => `${d.name}@${d.version}`).join(', ')}`,
247+
)
248+
const findings = await checkMalwareBatched(cdnDeps)
249+
reportAndThrow(findings, 'walkthrough generation')
250+
}
251+
252+
function reportAndThrow(findings: AuditFinding[], scope: string): void {
253+
if (findings.length === 0) {
254+
console.log(`[audit-deps] ${scope}: clean`)
255+
return
256+
}
257+
console.error(`[audit-deps] ${scope}: BLOCKED by Socket.dev`)
258+
for (const f of findings) {
259+
const alertsStr = f.alerts
260+
.map(a => `${a.type} (${a.severity ?? 'unspecified'})`)
261+
.join(', ')
262+
console.error(
263+
` ${f.dep.name}@${f.dep.version} [${f.dep.source}] — ${alertsStr}`,
264+
)
265+
}
266+
throw new Error(
267+
`${findings.length} package${findings.length === 1 ? '' : 's'} flagged — aborting.`,
268+
)
269+
}

scripts/walkthrough.mts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { fileURLToPath } from 'node:url'
2929
import { transform as esbuildTransform } from 'esbuild'
3030
import { transform as lightningTransform } from 'lightningcss'
3131

32+
import { auditCdnScripts, auditValDeps } from './audit-deps.mts'
33+
3234
const MEANDER_PATH = 'upstream/meander'
3335

3436
const here = path.dirname(fileURLToPath(import.meta.url))
@@ -362,6 +364,16 @@ async function generate(
362364
writeFileSync(htmlPath, html)
363365
}
364366

367+
// Socket.dev malware audit on CDN scripts (marked, highlight.js)
368+
// that meander's generated HTML loads via `<script src=unpkg...>`.
369+
// Runs before minification so a failure aborts early. Skip the
370+
// audit via SKIP_AUDIT=1 for offline dev; CI must not set this.
371+
if (!process.env['SKIP_AUDIT']) {
372+
await auditCdnScripts(walkthroughDir)
373+
} else {
374+
console.warn('[audit-deps] SKIP_AUDIT=1 — CDN audit SKIPPED')
375+
}
376+
365377
if (minify) {
366378
await minifyEmittedAssets()
367379
}
@@ -636,6 +648,16 @@ async function deployVal(args: readonly string[]): Promise<void> {
636648
)
637649
}
638650

651+
// Socket.dev malware audit on the val's transitive npm closure.
652+
// Fails fast before we upload anything — never ship flagged deps.
653+
// Skip with SKIP_AUDIT=1 for offline dev if the API is unreachable;
654+
// CI must not set this.
655+
if (!process.env['SKIP_AUDIT']) {
656+
await auditValDeps(repoRoot)
657+
} else {
658+
console.warn('[audit-deps] SKIP_AUDIT=1 — malware audit SKIPPED')
659+
}
660+
639661
const nameArg = args.find(a => a.startsWith('--name='))
640662
const valName = nameArg ? nameArg.slice('--name='.length) : 'walkthrough'
641663

val/audit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'
10-
import type { Context } from 'npm:hono@4'
10+
import type { Context } from 'npm:hono@4.12.14'
1111
import { scrubIp } from './validate.ts'
1212
import type { AppEnv } from './types.ts'
1313

val/auth-routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* can compose modules without circular imports.
55
*/
66

7-
import type { Hono, Next, Context } from 'npm:hono@4'
7+
import type { Hono, Next, Context } from 'npm:hono@4.12.14'
88
import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'
99
import { email as sendEmail } from 'https://esm.town/v/std/email'
1010

val/comment-routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* else's comment).
88
*/
99

10-
import type { Context, Hono, Next } from 'npm:hono@4'
10+
import type { Context, Hono, Next } from 'npm:hono@4.12.14'
1111
import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'
1212

1313
import { isValidSlug, isValidUuid, validateCommentInput } from './validate.ts'

val/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
* TRUSTED_PROXY_HOPS default 1 (Val Town edge)
3434
*/
3535

36-
import { Hono } from 'npm:hono@4'
36+
import { Hono } from 'npm:hono@4.12.14'
3737
import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'
3838

3939
import { importHmacKey } from './crypto.ts'

val/middleware.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* and the requireAuth gate used by every protected route.
44
*/
55

6-
import type { Context, Next } from 'npm:hono@4'
7-
import { cors } from 'npm:hono@4/cors'
6+
import type { Context, Next } from 'npm:hono@4.12.14'
7+
import { cors } from 'npm:hono@4.12.14/cors'
88
import { verifyJwt } from './crypto.ts'
99
import { isJtiRevoked } from './db.ts'
1010
import { ALLOWED_ORIGINS } from './config.ts'

0 commit comments

Comments
 (0)