dsh-standards 1.2.2 → 1.3.0
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/40-checker/README.md +18 -1
- package/40-checker/scripts/build-offline-kit.mjs +284 -0
- package/40-checker/scripts/build-sidecar.mjs +484 -0
- package/40-checker/scripts/flow-collect.mjs +7 -1
- package/40-checker/scripts/level-audit.mjs +45 -7
- package/40-checker/scripts/run-tests.mjs +3 -7
- package/40-checker/src/author-cli.mjs +139 -0
- package/40-checker/src/author-report.mjs +260 -0
- package/40-checker/src/cli.mjs +73 -8
- package/40-checker/src/flow-evidence.mjs +106 -1
- package/40-checker/src/index.mjs +3 -3
- package/40-checker/src/report.mjs +125 -24
- package/CHANGELOG.md +21 -0
- package/README.en.md +7 -2
- package/README.es.md +7 -2
- package/README.hi.md +7 -2
- package/README.md +7 -2
- package/README.pt.md +7 -2
- package/bin/dsh-standards.mjs +13 -2
- package/docs/assets/dsh-standards-flow-v3-dark.svg +87 -0
- package/docs/assets/dsh-standards-flow-v3-light.svg +87 -0
- package/docs/assets/dsh-standards-flow-v4-dark.svg +79 -0
- package/docs/assets/dsh-standards-flow-v4-light.svg +79 -0
- package/docs/assets/dsh-standards-hero-en-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-en-light.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-dark.webp +0 -0
- package/docs/assets/dsh-standards-hero-zh-light.webp +0 -0
- package/package.json +1 -1
- package/spec/README.md +2 -0
- package/spec/flow-evidence.schema.json +6 -2
- package/spec/schemas/advice.schema.json +92 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* build-sidecar — generate or refresh `publish/sidecar.json`
|
|
4
|
+
* (`dsh-market-sidecar/1`), the index cache the market can read without
|
|
5
|
+
* executing a plugin and without treating us as an issuer.
|
|
6
|
+
*
|
|
7
|
+
* Two modes:
|
|
8
|
+
* fresh read one `dsh-level-audit/1` JSON (`dsh-levels <dir> --format
|
|
9
|
+
* json`) plus a package tarball, emit one entry keyed name@version.
|
|
10
|
+
* lifecycle read an existing sidecar (`--prev`) and only recompute
|
|
11
|
+
* `lifecycle` from `--today` and `publish/withdrawals.json`.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately absent: advice (author view), issuer / recordHash / verifyUrl
|
|
14
|
+
* (we are not an issuer). `--today` pins the clock so CI and tests are
|
|
15
|
+
* reproducible.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node 40-checker/scripts/build-sidecar.mjs --levels audit.json \
|
|
19
|
+
* --name dsh-security-pack --version 3.0.1 --tarball dsh-security-pack-3.0.1.tgz \
|
|
20
|
+
* --out publish/sidecar.json --today 2026-11-06
|
|
21
|
+
*
|
|
22
|
+
* node 40-checker/scripts/build-sidecar.mjs --prev publish/sidecar.json \
|
|
23
|
+
* --withdrawals publish/withdrawals.json --out publish/sidecar.json \
|
|
24
|
+
* --today 2026-11-06
|
|
25
|
+
*/
|
|
26
|
+
import fs from 'node:fs'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
import crypto from 'node:crypto'
|
|
29
|
+
import { execFileSync } from 'node:child_process'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
|
|
32
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
33
|
+
const ROOT = path.resolve(HERE, '..', '..')
|
|
34
|
+
const SCHEMA = 'dsh-market-sidecar/1'
|
|
35
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
36
|
+
const HEX40 = /^[0-9a-f]{40}$/
|
|
37
|
+
const HEX64 = /^[0-9a-f]{64}$/
|
|
38
|
+
const LIFECYCLE = new Set(['active', 'stale', 'under-review', 'withdrawn'])
|
|
39
|
+
const FORBIDDEN = new Set(['advice', 'issuer', 'recordHash', 'verifyUrl'])
|
|
40
|
+
const NOT_CLAIMED = ['official', 'certification', 'security-audit', 'endorsement', 'ordering', 'comparison', 'warranty']
|
|
41
|
+
|
|
42
|
+
function usage() {
|
|
43
|
+
console.log(`build-sidecar — publish/sidecar.json (${SCHEMA})
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
node 40-checker/scripts/build-sidecar.mjs --levels <audit.json> [options]
|
|
47
|
+
node 40-checker/scripts/build-sidecar.mjs --prev <sidecar.json> [options]
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--levels <file> one dsh-level-audit/1 JSON (single package)
|
|
51
|
+
--prev <file> existing sidecar; entries survive a lifecycle refresh
|
|
52
|
+
--withdrawals <file> append-only withdrawal list (default: alongside --prev)
|
|
53
|
+
--out <file> output path, default publish/sidecar.json
|
|
54
|
+
--today <YYYY-MM-DD> fixed clock for generatedAt/checkedAt/lifecycle
|
|
55
|
+
--ttl-days <n> validity policy, default 90 (writes "90d")
|
|
56
|
+
--name <name> subject name; default package.json / audit.subject
|
|
57
|
+
--version <version> subject version; default package.json / audit.subject
|
|
58
|
+
--package <dir> package.json directory for name/version
|
|
59
|
+
--tarball <file> package tarball; sha256 is recorded in subject
|
|
60
|
+
--tarball-sha256 <hex> explicit tarball sha256 (checked against --tarball)
|
|
61
|
+
--evidence-url <url> optional https evidence URL
|
|
62
|
+
--evidence-file <file> hash this file into evidence.sha256
|
|
63
|
+
--evidence-sha256 <hex> explicit evidence sha256
|
|
64
|
+
--checker-version <v> default dsh-standards package.json version
|
|
65
|
+
--checker-sha <40hex> default git HEAD when available
|
|
66
|
+
--index-url <url> default https://awesome-dsh-plugin.com/index.json
|
|
67
|
+
--organizer sets selfAssessed=false (default true)
|
|
68
|
+
--help show this help
|
|
69
|
+
|
|
70
|
+
Exit codes: 0 written/unchanged; 3 invalid input.`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readJson(file, label) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new Error(`${label} is not readable JSON (${file}): ${error.message}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sha256File(file) {
|
|
82
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeDate(value, label) {
|
|
86
|
+
const match = String(value ?? '').match(/^(\d{4}-\d{2}-\d{2})(?:[T ].*)?$/)
|
|
87
|
+
if (!match) throw new Error(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`)
|
|
88
|
+
const day = match[1]
|
|
89
|
+
const parsed = new Date(`${day}T00:00:00.000Z`)
|
|
90
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) {
|
|
91
|
+
throw new Error(`${label} is not a real date: ${day}`)
|
|
92
|
+
}
|
|
93
|
+
return day
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function addDays(day, count) {
|
|
97
|
+
const date = new Date(`${day}T00:00:00.000Z`)
|
|
98
|
+
date.setUTCDate(date.getUTCDate() + count)
|
|
99
|
+
return date.toISOString().slice(0, 10)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function todayUtc() {
|
|
103
|
+
return new Date().toISOString().slice(0, 10)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseArgs(argv) {
|
|
107
|
+
const options = {
|
|
108
|
+
levels: null, prev: null, withdrawals: null,
|
|
109
|
+
out: 'publish/sidecar.json',
|
|
110
|
+
today: null, todayProvided: false, ttlDays: 90,
|
|
111
|
+
name: null, version: null, packageDir: null,
|
|
112
|
+
tarball: null, tarballSha256: null,
|
|
113
|
+
evidenceUrl: null, evidenceFile: null, evidenceSha256: null,
|
|
114
|
+
checkerVersion: null, checkerSha: null,
|
|
115
|
+
indexUrl: 'https://awesome-dsh-plugin.com/index.json',
|
|
116
|
+
selfAssessed: true,
|
|
117
|
+
}
|
|
118
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
119
|
+
const arg = argv[i]
|
|
120
|
+
const next = () => {
|
|
121
|
+
const value = argv[i + 1]
|
|
122
|
+
if (value === undefined) throw new Error(`${arg} needs a value`)
|
|
123
|
+
i += 1
|
|
124
|
+
return value
|
|
125
|
+
}
|
|
126
|
+
if (arg === '--levels') options.levels = next()
|
|
127
|
+
else if (arg === '--prev') options.prev = next()
|
|
128
|
+
else if (arg === '--withdrawals') options.withdrawals = next()
|
|
129
|
+
else if (arg === '--out') options.out = next()
|
|
130
|
+
else if (arg === '--today') { options.today = normalizeDate(next(), '--today'); options.todayProvided = true }
|
|
131
|
+
else if (arg === '--ttl-days') options.ttlDays = Number(next())
|
|
132
|
+
else if (arg === '--name') options.name = next()
|
|
133
|
+
else if (arg === '--version') options.version = next()
|
|
134
|
+
else if (arg === '--package') options.packageDir = next()
|
|
135
|
+
else if (arg === '--tarball') options.tarball = next()
|
|
136
|
+
else if (arg === '--tarball-sha256') options.tarballSha256 = next().toLowerCase()
|
|
137
|
+
else if (arg === '--evidence-url') options.evidenceUrl = next()
|
|
138
|
+
else if (arg === '--evidence-file') options.evidenceFile = next()
|
|
139
|
+
else if (arg === '--evidence-sha256') options.evidenceSha256 = next().toLowerCase()
|
|
140
|
+
else if (arg === '--checker-version') options.checkerVersion = next()
|
|
141
|
+
else if (arg === '--checker-sha') options.checkerSha = next().toLowerCase()
|
|
142
|
+
else if (arg === '--index-url') options.indexUrl = next()
|
|
143
|
+
else if (arg === '--organizer') options.selfAssessed = false
|
|
144
|
+
else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
|
|
145
|
+
else throw new Error(`unknown option: ${arg}`)
|
|
146
|
+
}
|
|
147
|
+
if (!options.levels && !options.prev) throw new Error('one of --levels or --prev is required')
|
|
148
|
+
if (!Number.isInteger(options.ttlDays) || options.ttlDays < 1 || options.ttlDays > 3650) {
|
|
149
|
+
throw new Error('--ttl-days must be an integer between 1 and 3650')
|
|
150
|
+
}
|
|
151
|
+
return options
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readPackageMeta(directory) {
|
|
155
|
+
if (!directory) return {}
|
|
156
|
+
const file = path.join(directory, 'package.json')
|
|
157
|
+
if (!fs.existsSync(file)) return {}
|
|
158
|
+
try {
|
|
159
|
+
return readJson(file, 'package.json')
|
|
160
|
+
} catch {
|
|
161
|
+
return {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function pickLevel(row, keys, fallback) {
|
|
166
|
+
const level = {}
|
|
167
|
+
if (row && typeof row === 'object') {
|
|
168
|
+
for (const key of keys) {
|
|
169
|
+
if (typeof row[key] === 'string' && row[key] !== '') level[key] = row[key]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!level.state) level.state = fallback
|
|
173
|
+
return level
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function gitHead() {
|
|
177
|
+
try {
|
|
178
|
+
const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
179
|
+
cwd: ROOT,
|
|
180
|
+
encoding: 'utf8',
|
|
181
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
182
|
+
}).trim()
|
|
183
|
+
return HEX40.test(sha) ? sha : null
|
|
184
|
+
} catch {
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function loadPrev(file) {
|
|
190
|
+
const prev = readJson(file, 'previous sidecar')
|
|
191
|
+
if (prev.schema !== SCHEMA) throw new Error(`previous sidecar schema is ${JSON.stringify(prev.schema)}, expected ${SCHEMA}`)
|
|
192
|
+
if (!prev.entries || typeof prev.entries !== 'object' || Array.isArray(prev.entries)) {
|
|
193
|
+
throw new Error('previous sidecar has no entries object')
|
|
194
|
+
}
|
|
195
|
+
if (typeof prev.generatedAt !== 'string') throw new Error('previous sidecar has no generatedAt')
|
|
196
|
+
return prev
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseWithdrawals(file) {
|
|
200
|
+
const result = { exact: new Map(), names: new Map(), records: 0 }
|
|
201
|
+
if (!file) return result
|
|
202
|
+
const raw = readJson(file, 'withdrawals')
|
|
203
|
+
let rows
|
|
204
|
+
if (Array.isArray(raw)) rows = raw
|
|
205
|
+
else if (Array.isArray(raw.withdrawals)) rows = raw.withdrawals
|
|
206
|
+
else if (raw.withdrawals && typeof raw.withdrawals === 'object') {
|
|
207
|
+
rows = Object.entries(raw.withdrawals).map(([key, value]) => ({ key, ...(typeof value === 'string' ? { reason: value } : value) }))
|
|
208
|
+
} else if (raw.entries && typeof raw.entries === 'object') {
|
|
209
|
+
rows = Object.entries(raw.entries).map(([key, value]) => ({ key, ...(typeof value === 'string' ? { reason: value } : value) }))
|
|
210
|
+
} else {
|
|
211
|
+
throw new Error('withdrawals must be an array or {"withdrawals":[...]} / {"entries":{...}}')
|
|
212
|
+
}
|
|
213
|
+
for (const row of rows) {
|
|
214
|
+
if (!row || typeof row !== 'object') throw new Error('every withdrawal record must be an object')
|
|
215
|
+
let name = row.name
|
|
216
|
+
let version = row.version
|
|
217
|
+
if (row.key) {
|
|
218
|
+
const at = String(row.key).lastIndexOf('@')
|
|
219
|
+
if (at <= 0) throw new Error(`withdrawal key must look like name@version: ${row.key}`)
|
|
220
|
+
name = String(row.key).slice(0, at)
|
|
221
|
+
version = String(row.key).slice(at + 1)
|
|
222
|
+
}
|
|
223
|
+
if (typeof name !== 'string' || name === '') throw new Error('withdrawal record needs a name')
|
|
224
|
+
const record = {}
|
|
225
|
+
const since = row.date ?? row.withdrawnAt ?? row.since
|
|
226
|
+
if (since !== undefined) record.since = normalizeDate(since, 'withdrawal date')
|
|
227
|
+
if (row.reason !== undefined) record.reason = String(row.reason)
|
|
228
|
+
result.records += 1
|
|
229
|
+
if (version !== undefined && version !== null && version !== '') {
|
|
230
|
+
result.exact.set(`${name}@${version}`, record)
|
|
231
|
+
} else {
|
|
232
|
+
result.names.set(name, record)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return result
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function entryFromAudit(audit, options, levelFile) {
|
|
239
|
+
const targetDir = audit.target?.dir
|
|
240
|
+
? path.resolve(path.dirname(levelFile), audit.target.dir)
|
|
241
|
+
: options.packageDir
|
|
242
|
+
const meta = readPackageMeta(options.packageDir ?? targetDir)
|
|
243
|
+
const name = options.name ?? audit.subject?.name ?? meta.name
|
|
244
|
+
const version = options.version ?? audit.subject?.version ?? meta.version
|
|
245
|
+
if (typeof name !== 'string' || name === '') throw new Error('cannot determine subject.name; pass --name or --package')
|
|
246
|
+
if (typeof version !== 'string' || version === '') throw new Error('cannot determine subject.version; pass --version or --package')
|
|
247
|
+
|
|
248
|
+
const auditLevels = audit.levels && typeof audit.levels === 'object' ? audit.levels : {}
|
|
249
|
+
const levels = {
|
|
250
|
+
entry: pickLevel(auditLevels.entry, ['state', 'claim'], 'not-run'),
|
|
251
|
+
flow: pickLevel(auditLevels.flow, ['state'], 'not-run'),
|
|
252
|
+
boost: pickLevel(auditLevels.boost, ['state'], 'not-measured'),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let checkedAt
|
|
256
|
+
try {
|
|
257
|
+
checkedAt = normalizeDate(audit.generatedAt, 'audit.generatedAt')
|
|
258
|
+
} catch {
|
|
259
|
+
checkedAt = options.today ?? todayUtc()
|
|
260
|
+
}
|
|
261
|
+
const expiresAt = addDays(checkedAt, options.ttlDays)
|
|
262
|
+
|
|
263
|
+
let tarballSha256 = options.tarballSha256 ?? audit.subject?.tarballSha256 ?? null
|
|
264
|
+
if (tarballSha256 !== null) tarballSha256 = String(tarballSha256).toLowerCase()
|
|
265
|
+
if (options.tarball) {
|
|
266
|
+
const computed = sha256File(options.tarball)
|
|
267
|
+
if (tarballSha256 !== null && tarballSha256 !== computed) {
|
|
268
|
+
throw new Error(`--tarball-sha256 does not match ${options.tarball}`)
|
|
269
|
+
}
|
|
270
|
+
tarballSha256 = computed
|
|
271
|
+
}
|
|
272
|
+
if (tarballSha256 !== null && !HEX64.test(tarballSha256)) throw new Error('tarball sha256 must be 64 hex chars')
|
|
273
|
+
|
|
274
|
+
let evidenceSha256 = options.evidenceSha256 ?? null
|
|
275
|
+
if (options.evidenceFile) {
|
|
276
|
+
const computed = sha256File(options.evidenceFile)
|
|
277
|
+
if (evidenceSha256 !== null && evidenceSha256 !== computed) {
|
|
278
|
+
throw new Error('--evidence-sha256 does not match --evidence-file')
|
|
279
|
+
}
|
|
280
|
+
evidenceSha256 = computed
|
|
281
|
+
}
|
|
282
|
+
if (evidenceSha256 !== null && !HEX64.test(evidenceSha256)) throw new Error('evidence sha256 must be 64 hex chars')
|
|
283
|
+
|
|
284
|
+
const evidence = {}
|
|
285
|
+
if (options.evidenceUrl) {
|
|
286
|
+
let parsed
|
|
287
|
+
try { parsed = new URL(options.evidenceUrl) } catch { throw new Error(`--evidence-url is not a URL: ${options.evidenceUrl}`) }
|
|
288
|
+
if (parsed.protocol !== 'https:') throw new Error('--evidence-url must be https')
|
|
289
|
+
evidence.url = options.evidenceUrl
|
|
290
|
+
}
|
|
291
|
+
if (evidenceSha256 !== null) evidence.sha256 = evidenceSha256
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
key: `${name}@${version}`,
|
|
295
|
+
entry: {
|
|
296
|
+
subject: { name, version, tarballSha256 },
|
|
297
|
+
levels,
|
|
298
|
+
evidence,
|
|
299
|
+
validity: { checkedAt, expiresAt, policy: `${options.ttlDays}d` },
|
|
300
|
+
lifecycle: 'active',
|
|
301
|
+
selfAssessed: options.selfAssessed,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function withdrawalFor(key, entry, withdrawals, previous) {
|
|
307
|
+
if (withdrawals.exact.has(key)) return withdrawals.exact.get(key)
|
|
308
|
+
const byName = withdrawals.names.get(entry.subject.name)
|
|
309
|
+
if (byName) return byName
|
|
310
|
+
if (previous?.lifecycle === 'withdrawn') {
|
|
311
|
+
return previous.lifecycleDetail ?? {}
|
|
312
|
+
}
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function applyLifecycle(entries, previousEntries, options, withdrawals) {
|
|
317
|
+
const counts = { active: 0, stale: 0, 'under-review': 0, withdrawn: 0 }
|
|
318
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
319
|
+
const previous = previousEntries?.[key]
|
|
320
|
+
const withdrawn = withdrawalFor(key, entry, withdrawals, previous)
|
|
321
|
+
if (withdrawn) {
|
|
322
|
+
entry.lifecycle = 'withdrawn'
|
|
323
|
+
if (Object.keys(withdrawn).length > 0) entry.lifecycleDetail = withdrawn
|
|
324
|
+
else if (previous?.lifecycleDetail) entry.lifecycleDetail = previous.lifecycleDetail
|
|
325
|
+
else delete entry.lifecycleDetail
|
|
326
|
+
} else if (previous?.lifecycle === 'under-review') {
|
|
327
|
+
entry.lifecycle = 'under-review'
|
|
328
|
+
if (previous.lifecycleDetail) entry.lifecycleDetail = previous.lifecycleDetail
|
|
329
|
+
} else if (entry.validity?.expiresAt && options.today > entry.validity.expiresAt) {
|
|
330
|
+
entry.lifecycle = 'stale'
|
|
331
|
+
} else {
|
|
332
|
+
entry.lifecycle = 'active'
|
|
333
|
+
}
|
|
334
|
+
if (entry.lifecycle === 'active') delete entry.lifecycleDetail
|
|
335
|
+
else if (entry.lifecycle === 'stale') delete entry.lifecycleDetail
|
|
336
|
+
counts[entry.lifecycle] = (counts[entry.lifecycle] ?? 0) + 1
|
|
337
|
+
}
|
|
338
|
+
return counts
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function walkKeys(value, trail, callback) {
|
|
342
|
+
if (!value || typeof value !== 'object') return
|
|
343
|
+
if (Array.isArray(value)) {
|
|
344
|
+
value.forEach((item, index) => walkKeys(item, `${trail}[${index}]`, callback))
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
for (const [key, child] of Object.entries(value)) {
|
|
348
|
+
callback(key, `${trail}.${key}`)
|
|
349
|
+
walkKeys(child, `${trail}.${key}`, callback)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validateSidecar(sidecar) {
|
|
354
|
+
if (sidecar.schema !== SCHEMA) throw new Error(`sidecar schema must be ${SCHEMA}`)
|
|
355
|
+
if (typeof sidecar.generatedAt !== 'string' || Number.isNaN(Date.parse(sidecar.generatedAt))) {
|
|
356
|
+
throw new Error('sidecar.generatedAt is not an ISO timestamp')
|
|
357
|
+
}
|
|
358
|
+
if (!sidecar.index || sidecar.index.format !== 1 || typeof sidecar.index.url !== 'string') {
|
|
359
|
+
throw new Error('sidecar.index must be {url, format:1}')
|
|
360
|
+
}
|
|
361
|
+
if (!sidecar.checker || typeof sidecar.checker.name !== 'string' || typeof sidecar.checker.version !== 'string') {
|
|
362
|
+
throw new Error('sidecar.checker must name a checker version')
|
|
363
|
+
}
|
|
364
|
+
if (sidecar.checker.sha !== null && !HEX40.test(String(sidecar.checker.sha))) {
|
|
365
|
+
throw new Error('sidecar.checker.sha must be null or 40 hex chars')
|
|
366
|
+
}
|
|
367
|
+
if (!Array.isArray(sidecar.notClaimed) || sidecar.notClaimed.length !== NOT_CLAIMED.length) {
|
|
368
|
+
throw new Error('sidecar.notClaimed must list the fixed not-claimed set')
|
|
369
|
+
}
|
|
370
|
+
if (!sidecar.entries || typeof sidecar.entries !== 'object' || Array.isArray(sidecar.entries)) {
|
|
371
|
+
throw new Error('sidecar.entries must be an object')
|
|
372
|
+
}
|
|
373
|
+
for (const [key, entry] of Object.entries(sidecar.entries)) {
|
|
374
|
+
if (!entry?.subject || typeof entry.subject.name !== 'string' || typeof entry.subject.version !== 'string') {
|
|
375
|
+
throw new Error(`${key}: subject.name/subject.version are required`)
|
|
376
|
+
}
|
|
377
|
+
if (key !== `${entry.subject.name}@${entry.subject.version}`) throw new Error(`${key}: key does not match subject`)
|
|
378
|
+
if (entry.subject.tarballSha256 !== null && !HEX64.test(String(entry.subject.tarballSha256))) {
|
|
379
|
+
throw new Error(`${key}: subject.tarballSha256 must be null or 64 hex chars`)
|
|
380
|
+
}
|
|
381
|
+
if (!entry.levels || typeof entry.levels !== 'object') throw new Error(`${key}: levels are required`)
|
|
382
|
+
for (const id of ['entry', 'flow', 'boost']) {
|
|
383
|
+
if (!entry.levels[id] || typeof entry.levels[id].state !== 'string' || entry.levels[id].state === '') {
|
|
384
|
+
throw new Error(`${key}: levels.${id}.state is required`)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!entry.validity || !DATE.test(String(entry.validity.checkedAt))) throw new Error(`${key}: validity.checkedAt is required`)
|
|
388
|
+
if (!DATE.test(String(entry.validity.expiresAt))) throw new Error(`${key}: validity.expiresAt is required`)
|
|
389
|
+
if (typeof entry.validity.policy !== 'string') throw new Error(`${key}: validity.policy is required`)
|
|
390
|
+
if (!LIFECYCLE.has(entry.lifecycle)) throw new Error(`${key}: lifecycle must be one of ${[...LIFECYCLE].join(', ')}`)
|
|
391
|
+
if (typeof entry.selfAssessed !== 'boolean') throw new Error(`${key}: selfAssessed must be boolean`)
|
|
392
|
+
if (!entry.evidence || typeof entry.evidence !== 'object' || Array.isArray(entry.evidence)) {
|
|
393
|
+
throw new Error(`${key}: evidence must be an object`)
|
|
394
|
+
}
|
|
395
|
+
if (entry.evidence.url !== undefined) {
|
|
396
|
+
let url
|
|
397
|
+
try { url = new URL(entry.evidence.url) } catch { throw new Error(`${key}: evidence.url is not a URL`) }
|
|
398
|
+
if (url.protocol !== 'https:') throw new Error(`${key}: evidence.url must be https`)
|
|
399
|
+
}
|
|
400
|
+
if (entry.evidence.sha256 !== undefined && !HEX64.test(String(entry.evidence.sha256))) {
|
|
401
|
+
throw new Error(`${key}: evidence.sha256 must be 64 hex chars`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
walkKeys(sidecar, 'sidecar', (key, where) => {
|
|
405
|
+
if (FORBIDDEN.has(key)) throw new Error(`${where} is forbidden in a sidecar (${[...FORBIDDEN].join(', ')})`)
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function main() {
|
|
410
|
+
const options = parseArgs(process.argv.slice(2))
|
|
411
|
+
const today = options.today ?? todayUtc()
|
|
412
|
+
const outFile = path.resolve(options.out)
|
|
413
|
+
const prev = options.prev ? loadPrev(path.resolve(options.prev)) : null
|
|
414
|
+
const withdrawalsFile = options.withdrawals
|
|
415
|
+
? path.resolve(options.withdrawals)
|
|
416
|
+
: prev && options.prev
|
|
417
|
+
? path.join(path.dirname(path.resolve(options.prev)), 'withdrawals.json')
|
|
418
|
+
: null
|
|
419
|
+
let withdrawals = { exact: new Map(), names: new Map(), records: 0 }
|
|
420
|
+
if (withdrawalsFile && fs.existsSync(withdrawalsFile)) withdrawals = parseWithdrawals(withdrawalsFile)
|
|
421
|
+
else if (options.withdrawals) throw new Error(`withdrawals file not found: ${withdrawalsFile}`)
|
|
422
|
+
|
|
423
|
+
const previousEntries = prev ? prev.entries : null
|
|
424
|
+
let entries = previousEntries ? JSON.parse(JSON.stringify(previousEntries)) : {}
|
|
425
|
+
let generatedAt = prev?.generatedAt ?? null
|
|
426
|
+
if (options.levels) {
|
|
427
|
+
const levelFile = path.resolve(options.levels)
|
|
428
|
+
const audit = readJson(levelFile, 'level audit')
|
|
429
|
+
if (!audit.levels || typeof audit.levels !== 'object') throw new Error('--levels must be a dsh-level-audit/1 JSON')
|
|
430
|
+
const built = entryFromAudit(audit, options, levelFile)
|
|
431
|
+
entries[built.key] = built.entry
|
|
432
|
+
generatedAt = options.todayProvided ? `${today}T00:00:00.000Z` : new Date().toISOString()
|
|
433
|
+
}
|
|
434
|
+
if (!generatedAt) throw new Error('no --prev and no --levels generatedAt')
|
|
435
|
+
|
|
436
|
+
const checkerPkg = readPackageMeta(ROOT)
|
|
437
|
+
const checkerVersion = options.checkerVersion ?? checkerPkg.version
|
|
438
|
+
if (typeof checkerVersion !== 'string' || checkerVersion === '') throw new Error('cannot determine checker version')
|
|
439
|
+
const checkerSha = options.checkerSha ?? gitHead()
|
|
440
|
+
if (checkerSha !== null && !HEX40.test(checkerSha)) throw new Error('--checker-sha must be 40 hex chars')
|
|
441
|
+
try {
|
|
442
|
+
const parsed = new URL(options.indexUrl)
|
|
443
|
+
if (parsed.protocol !== 'https:') throw new Error()
|
|
444
|
+
} catch {
|
|
445
|
+
throw new Error('--index-url must be https')
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const counts = applyLifecycle(entries, previousEntries, { today }, withdrawals)
|
|
449
|
+
const sortedEntries = Object.fromEntries(Object.keys(entries).sort().map(key => [key, entries[key]]))
|
|
450
|
+
const sidecar = {
|
|
451
|
+
schema: SCHEMA,
|
|
452
|
+
generatedAt,
|
|
453
|
+
index: { url: options.indexUrl, format: 1 },
|
|
454
|
+
checker: { name: 'dsh-standards', version: checkerVersion, sha: checkerSha },
|
|
455
|
+
notClaimed: [...NOT_CLAIMED],
|
|
456
|
+
entries: sortedEntries,
|
|
457
|
+
}
|
|
458
|
+
validateSidecar(sidecar)
|
|
459
|
+
|
|
460
|
+
const text = `${JSON.stringify(sidecar, null, 2)}\n`
|
|
461
|
+
let written = false
|
|
462
|
+
if (!fs.existsSync(outFile) || fs.readFileSync(outFile, 'utf8') !== text) {
|
|
463
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true })
|
|
464
|
+
fs.writeFileSync(outFile, text)
|
|
465
|
+
written = true
|
|
466
|
+
}
|
|
467
|
+
console.log(JSON.stringify({
|
|
468
|
+
out: outFile,
|
|
469
|
+
schema: SCHEMA,
|
|
470
|
+
generatedAt,
|
|
471
|
+
entries: Object.keys(sortedEntries).length,
|
|
472
|
+
lifecycle: counts,
|
|
473
|
+
withdrawals: { records: withdrawals.records, file: withdrawalsFile && fs.existsSync(withdrawalsFile) ? withdrawalsFile : null },
|
|
474
|
+
written,
|
|
475
|
+
unchanged: !written,
|
|
476
|
+
}, null, 2))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
main()
|
|
481
|
+
} catch (error) {
|
|
482
|
+
console.error(`build-sidecar: ${error.message}`)
|
|
483
|
+
process.exitCode = 3
|
|
484
|
+
}
|
|
@@ -36,7 +36,7 @@ import path from 'node:path'
|
|
|
36
36
|
import { spawnSync } from 'node:child_process'
|
|
37
37
|
import { fileURLToPath } from 'node:url'
|
|
38
38
|
import {
|
|
39
|
-
FLOW_IDS, FLOW_EVIDENCE_SCHEMA, clauseAnchor, finalizeEvidence, journeyAggregate,
|
|
39
|
+
FLOW_IDS, FLOW_EVIDENCE_SCHEMA, ciAttribution, clauseAnchor, finalizeEvidence, journeyAggregate,
|
|
40
40
|
sha256File, sha256Value, treeSha256, validateFlowEvidence,
|
|
41
41
|
} from '../src/flow-evidence.mjs'
|
|
42
42
|
import { sanitizedEnv } from '../src/sandbox-env.mjs'
|
|
@@ -77,6 +77,7 @@ Options:
|
|
|
77
77
|
--timeout <ms> per-command budget (default: 120000)
|
|
78
78
|
--static skip the runtime probe (J1/J2/J4/J6 become static/blocked)
|
|
79
79
|
--ci mark producer.kind=ci, claims.claimLevel=ci-attested
|
|
80
|
+
(CI attribution is also read from GITHUB_* and stays self-declared)
|
|
80
81
|
--sandbox run the runtime probe under unshare/prlimit/process-group
|
|
81
82
|
timeout with an allowlisted env; producer.kind=sandbox only
|
|
82
83
|
when every isolation item is effective (otherwise
|
|
@@ -452,6 +453,10 @@ async function main() {
|
|
|
452
453
|
? `sandboxed collector DEGRADED (${sandboxPlan.degraded.join(', ')}): allowlisted env, temporary HOME/DSH_HOME, process-group SIGKILL timeout; every missing isolation item is recorded`
|
|
453
454
|
: 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption'
|
|
454
455
|
const sandbox = sandboxPlan ? { ...sandboxPlan, executed: sandboxExecuted, execution: sandboxExecution } : null
|
|
456
|
+
// CI attribution is display-only and self-declared. It is recorded from the
|
|
457
|
+
// runner environment even without --ci; it never upgrades the claim level.
|
|
458
|
+
const ci = ciAttribution()
|
|
459
|
+
const ciFields = ci ? Object.fromEntries(Object.entries(ci).filter(([, value]) => value !== null)) : {}
|
|
455
460
|
const document = finalizeEvidence({
|
|
456
461
|
schema: FLOW_EVIDENCE_SCHEMA,
|
|
457
462
|
generatedAt,
|
|
@@ -460,6 +465,7 @@ async function main() {
|
|
|
460
465
|
at: generatedAt,
|
|
461
466
|
kind: producerKind,
|
|
462
467
|
runner: sandboxPlan ? 'flow-collect/2-sandbox' : 'flow-collect/1',
|
|
468
|
+
...ciFields,
|
|
463
469
|
},
|
|
464
470
|
...(sandbox ? { sandbox } : {}),
|
|
465
471
|
subject,
|
|
@@ -31,7 +31,7 @@ import fs from 'node:fs'
|
|
|
31
31
|
import path from 'node:path'
|
|
32
32
|
import { spawnSync } from 'node:child_process'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
34
|
-
import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, validateFlowEvidence } from '../src/flow-evidence.mjs'
|
|
34
|
+
import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, bindEvidenceSubject, validateFlowEvidence } from '../src/flow-evidence.mjs'
|
|
35
35
|
|
|
36
36
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
37
37
|
const STD = path.resolve(HERE, '..', '..')
|
|
@@ -52,6 +52,10 @@ Options:
|
|
|
52
52
|
--flow-evidence <file> dsh-flow-evidence/2 JSON from dsh-flow-collect.mjs (J1-J8 checks
|
|
53
53
|
with command/exitCode/times/stdout sha256/assertions). v1 and bare
|
|
54
54
|
{Jx:{status}} files are reported deprecated and never count as pass.
|
|
55
|
+
--dir <dir> bind the flow evidence to this package directory: name/version,
|
|
56
|
+
recomputed treeSha256 and npm-pack tarball hash must match the
|
|
57
|
+
evidence subject, otherwise FLOW becomes invalid. If the positional
|
|
58
|
+
directory is omitted, --dir is used for the ENTRY check too.
|
|
55
59
|
--boost-evidence <file> JSON: { "LEV-3": { "status": "measured|not-measured|failed", "evidenceCard": { ... } }, ... }
|
|
56
60
|
measured requires a valid evidence card; a status-only row is downgraded to not-measured
|
|
57
61
|
--timeout <ms> checker timeout, default 150000
|
|
@@ -65,7 +69,7 @@ are reported as not-run / not-measured.`)
|
|
|
65
69
|
|
|
66
70
|
function parseArgs(argv) {
|
|
67
71
|
const options = {
|
|
68
|
-
dir: null, host: null, base: null, bare: null, runtime: true,
|
|
72
|
+
dir: null, bindDir: null, host: null, base: null, bare: null, runtime: true,
|
|
69
73
|
flowEvidence: null, boostEvidence: null, timeout: 150_000,
|
|
70
74
|
format: 'text', out: null,
|
|
71
75
|
}
|
|
@@ -82,6 +86,7 @@ function parseArgs(argv) {
|
|
|
82
86
|
else if (arg === '--bare') options.bare = next()
|
|
83
87
|
else if (arg === '--no-runtime') options.runtime = false
|
|
84
88
|
else if (arg === '--flow-evidence') options.flowEvidence = next()
|
|
89
|
+
else if (arg === '--dir') options.bindDir = next()
|
|
85
90
|
else if (arg === '--boost-evidence') options.boostEvidence = next()
|
|
86
91
|
else if (arg === '--timeout') options.timeout = Number(next())
|
|
87
92
|
else if (arg === '--format') options.format = next()
|
|
@@ -91,11 +96,17 @@ function parseArgs(argv) {
|
|
|
91
96
|
else if (options.dir === null) options.dir = arg
|
|
92
97
|
else throw new Error(`unexpected argument: ${arg}`)
|
|
93
98
|
}
|
|
94
|
-
if (
|
|
99
|
+
if (options.bindDir !== null && options.dir !== null && path.resolve(options.bindDir) !== path.resolve(options.dir)) {
|
|
100
|
+
throw new Error('the positional directory and --dir point at different packages')
|
|
101
|
+
}
|
|
102
|
+
if (!options.dir) options.dir = options.bindDir
|
|
103
|
+
if (!options.dir) throw new Error('a plugin directory is required (positional or --dir)')
|
|
95
104
|
if (!['text', 'json'].includes(options.format)) throw new Error('--format must be text or json')
|
|
96
105
|
if (!Number.isFinite(options.timeout) || options.timeout < 1000) throw new Error('--timeout must be >= 1000')
|
|
97
106
|
options.dir = path.resolve(options.dir)
|
|
107
|
+
if (options.bindDir !== null) options.bindDir = path.resolve(options.bindDir)
|
|
98
108
|
if (!fs.existsSync(path.join(options.dir, 'package.json'))) throw new Error(`not a package directory: ${options.dir}`)
|
|
109
|
+
if (options.bindDir !== null && !fs.existsSync(path.join(options.bindDir, 'package.json'))) throw new Error(`not a package directory: ${options.bindDir}`)
|
|
99
110
|
return options
|
|
100
111
|
}
|
|
101
112
|
|
|
@@ -158,12 +169,21 @@ function classifyEntry(report, invoked) {
|
|
|
158
169
|
}
|
|
159
170
|
}
|
|
160
171
|
|
|
161
|
-
function
|
|
172
|
+
function attestationOf(evidence) {
|
|
173
|
+
const producer = evidence && typeof evidence.producer === 'object' ? evidence.producer : null
|
|
174
|
+
if (producer?.kind === 'ci') return 'ci'
|
|
175
|
+
if (typeof producer?.runUrl === 'string' && producer.runUrl !== '') return 'ci'
|
|
176
|
+
return 'self-declared'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function classifyFlow(evidence, binding = null) {
|
|
162
180
|
const ids = FLOW_IDS
|
|
163
181
|
const emptyJourneys = () => Object.fromEntries(ids.map(id => [id, { status: 'not-run', declared: null, checks: 0, passes: false }]))
|
|
164
182
|
if (!evidence) {
|
|
165
183
|
return {
|
|
166
184
|
state: 'not-run',
|
|
185
|
+
attestation: 'self-declared',
|
|
186
|
+
subject: binding,
|
|
167
187
|
schema: null,
|
|
168
188
|
claimLevel: null,
|
|
169
189
|
producerKind: null,
|
|
@@ -185,6 +205,8 @@ function classifyFlow(evidence) {
|
|
|
185
205
|
return row?.status ?? null
|
|
186
206
|
}
|
|
187
207
|
const base = {
|
|
208
|
+
attestation: attestationOf(evidence),
|
|
209
|
+
subject: binding,
|
|
188
210
|
schema: verdict.schema,
|
|
189
211
|
claimLevel: verdict.claimLevel ?? null,
|
|
190
212
|
producerKind: verdict.producerKind ?? null,
|
|
@@ -205,12 +227,19 @@ function classifyFlow(evidence) {
|
|
|
205
227
|
journeys: Object.fromEntries(ids.map(id => [id, { status: 'deprecated', declared: declaredOf(id), checks: 0, passes: false }])),
|
|
206
228
|
}
|
|
207
229
|
}
|
|
208
|
-
|
|
230
|
+
const bindingErrors = binding && !binding.ok
|
|
231
|
+
? [...(binding.errors ?? []), ...(binding.mismatches ?? [])].map(message => `subject binding: ${message}`)
|
|
232
|
+
: []
|
|
233
|
+
if (!verdict.valid || bindingErrors.length > 0) {
|
|
234
|
+
const errors = [...verdict.errors, ...bindingErrors]
|
|
209
235
|
return {
|
|
210
236
|
...base,
|
|
211
237
|
state: 'invalid',
|
|
212
238
|
invalid: true,
|
|
213
|
-
|
|
239
|
+
errors,
|
|
240
|
+
reason: bindingErrors.length > 0
|
|
241
|
+
? `evidence subject does not match the inspected package: ${[...(binding.errors ?? []), ...(binding.mismatches ?? [])].join('; ')}`
|
|
242
|
+
: `${FLOW_EVIDENCE_SCHEMA} failed structural validation: ${verdict.errors.slice(0, 5).join('; ')}${verdict.errors.length > 5 ? ` (+${verdict.errors.length - 5} more)` : ''}`,
|
|
214
243
|
evidenceHash: {
|
|
215
244
|
declared: verdict.declaredHash,
|
|
216
245
|
computed: verdict.computedHash,
|
|
@@ -391,6 +420,7 @@ function render(result) {
|
|
|
391
420
|
`DSH level audit — ${result.target.dir}`,
|
|
392
421
|
` ENTRY : ${result.levels.entry.state.padEnd(12)} (${result.levels.entry.checkerLevel ?? 'no level'})`,
|
|
393
422
|
` FLOW : ${result.levels.flow.state.padEnd(12)} ${result.levels.flow.reason}`,
|
|
423
|
+
` attest: ${result.levels.flow.attestation}${result.levels.flow.subject ? ` (subject ${result.levels.flow.subject.ok ? 'bound' : 'MISMATCH'})` : ''}`,
|
|
394
424
|
` BOOST : ${result.levels.boost.state.padEnd(12)} ${result.levels.boost.reason}`,
|
|
395
425
|
]
|
|
396
426
|
if (result.nextActions.length > 0) {
|
|
@@ -409,7 +439,13 @@ function main() {
|
|
|
409
439
|
|
|
410
440
|
const checker = runChecker(options)
|
|
411
441
|
const entry = classifyEntry(checker.report, checker.invoked)
|
|
412
|
-
|
|
442
|
+
// Subject binding is opt-in via --dir: it re-reads the package and re-packs
|
|
443
|
+
// it, so an evidence file can no longer vouch for a different version/hash.
|
|
444
|
+
let binding = null
|
|
445
|
+
if (flowEvidence && options.bindDir) {
|
|
446
|
+
binding = bindEvidenceSubject(flowEvidence, options.bindDir, { timeoutMs: options.timeout })
|
|
447
|
+
}
|
|
448
|
+
const flow = classifyFlow(flowEvidence, binding)
|
|
413
449
|
const boost = classifyBoost(boostEvidence)
|
|
414
450
|
|
|
415
451
|
const result = {
|
|
@@ -418,6 +454,8 @@ function main() {
|
|
|
418
454
|
target: { dir: options.dir },
|
|
419
455
|
levelsSpec: { schema: levels.schema, version: levels.version, status: levels.status },
|
|
420
456
|
levels: { entry, flow, boost },
|
|
457
|
+
attestation: flow.attestation,
|
|
458
|
+
subject: binding,
|
|
421
459
|
nextActions: nextActions(entry, flow, boost),
|
|
422
460
|
notClaimed: ['official standard', 'certification', 'security audit', 'ranking', 'production baseline'],
|
|
423
461
|
checker: {
|
|
@@ -7,14 +7,10 @@ import { dirname, join } from 'node:path'
|
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
|
|
9
9
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
console.log('checker tests are not shipped in the npm package; nothing to run')
|
|
13
|
-
process.exit(0)
|
|
14
|
-
}
|
|
15
|
-
const files = readdirSync(testDir).filter((f) => f.endsWith('.test.mjs')).map((f) => join(testDir, f))
|
|
10
|
+
const dirs = [join(here, '..', 'test'), join(here, '..', '..', 'test')]
|
|
11
|
+
const files = dirs.filter((dir) => existsSync(dir)).flatMap((dir) => readdirSync(dir).filter((f) => f.endsWith('.test.mjs')).map((f) => join(dir, f)))
|
|
16
12
|
if (files.length === 0) {
|
|
17
|
-
console.log('
|
|
13
|
+
console.log('checker tests are not shipped in the npm package; nothing to run')
|
|
18
14
|
process.exit(0)
|
|
19
15
|
}
|
|
20
16
|
const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
|