dsh-standards 1.4.1 → 1.6.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/fixtures/security/sec15-clean/index.mjs +3 -0
- package/40-checker/fixtures/security/sec15-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec15-missing-ref/index.mjs +3 -0
- package/40-checker/fixtures/security/sec15-missing-ref/package.json +22 -0
- package/40-checker/fixtures/security/sec15-node/index.mjs +3 -0
- package/40-checker/fixtures/security/sec15-node/package.json +22 -0
- package/40-checker/fixtures/security/sec15-node/scripts/build.mjs +1 -0
- package/40-checker/fixtures/security/sec15-pipe/index.mjs +3 -0
- package/40-checker/fixtures/security/sec15-pipe/package.json +22 -0
- package/40-checker/fixtures/security/sec16-client/index.mjs +3 -0
- package/40-checker/fixtures/security/sec16-client/lib/client.js +3 -0
- package/40-checker/fixtures/security/sec16-client/package.json +21 -0
- package/40-checker/fixtures/security/sec16-declared/index.mjs +3 -0
- package/40-checker/fixtures/security/sec16-declared/package.json +19 -0
- package/40-checker/fixtures/security/sec16-eval/index.mjs +3 -0
- package/40-checker/fixtures/security/sec16-eval/package.json +19 -0
- package/40-checker/fixtures/security/sec16-require/index.mjs +4 -0
- package/40-checker/fixtures/security/sec16-require/package.json +19 -0
- package/40-checker/fixtures/security/sec17-env/index.mjs +5 -0
- package/40-checker/fixtures/security/sec17-env/package.json +19 -0
- package/40-checker/fixtures/security/sec17-seam/index.mjs +3 -0
- package/40-checker/fixtures/security/sec17-seam/package.json +19 -0
- package/40-checker/fixtures/security/sec17-ssh/index.mjs +6 -0
- package/40-checker/fixtures/security/sec17-ssh/package.json +19 -0
- package/40-checker/fixtures/security/sec18-bad/index.mjs +3 -0
- package/40-checker/fixtures/security/sec18-bad/package.json +21 -0
- package/40-checker/fixtures/security/sec18-good/index.mjs +3 -0
- package/40-checker/fixtures/security/sec18-good/package.json +26 -0
- package/40-checker/fixtures/security/sec18-undeclared/index.mjs +3 -0
- package/40-checker/fixtures/security/sec18-undeclared/package.json +26 -0
- package/40-checker/fixtures/security/sec19-blob/index.mjs +2 -0
- package/40-checker/fixtures/security/sec19-blob/package.json +19 -0
- package/40-checker/fixtures/security/sec19-clean/index.mjs +2 -0
- package/40-checker/fixtures/security/sec19-clean/package.json +19 -0
- package/40-checker/fixtures/security/sec19-wasm/blob.wasm +0 -0
- package/40-checker/fixtures/security/sec19-wasm/index.mjs +3 -0
- package/40-checker/fixtures/security/sec19-wasm/package.json +19 -0
- package/40-checker/fixtures/security/sec20-unwrapped/index.mjs +4 -0
- package/40-checker/fixtures/security/sec20-unwrapped/package.json +23 -0
- package/40-checker/fixtures/security/sec20-wrapped/index.mjs +3 -0
- package/40-checker/fixtures/security/sec20-wrapped/package.json +23 -0
- package/40-checker/fixtures/security/sec21-known/index.mjs +3 -0
- package/40-checker/fixtures/security/sec21-known/package.json +23 -0
- package/40-checker/fixtures/security/sec21-typosquat/index.mjs +3 -0
- package/40-checker/fixtures/security/sec21-typosquat/package.json +22 -0
- package/40-checker/fixtures/security/sec22-attested/index.mjs +3 -0
- package/40-checker/fixtures/security/sec22-attested/package.json +22 -0
- package/40-checker/fixtures/security/sec22-none/index.mjs +3 -0
- package/40-checker/fixtures/security/sec22-none/package.json +19 -0
- package/40-checker/scripts/sign-evidence.mjs +169 -0
- package/40-checker/src/cli.mjs +402 -73
- package/40-checker/src/declarations.mjs +10 -10
- package/40-checker/src/flow-evidence.mjs +21 -0
- package/40-checker/src/index.mjs +2 -1
- package/40-checker/src/report.mjs +116 -4
- package/40-checker/src/runtime-child.mjs +68 -0
- package/40-checker/src/runtime.mjs +3 -1
- package/40-checker/src/sandbox-run.mjs +406 -35
- package/40-checker/src/security.mjs +1065 -0
- package/40-checker/src/signature.mjs +249 -0
- package/40-checker/src/util.mjs +53 -0
- package/CHANGELOG.md +34 -0
- package/docs/SIGNING.md +137 -0
- package/docs/coverage.md +39 -6
- package/package.json +1 -1
- package/spec/coverage-contract.md +33 -11
- package/spec/dsc-clauses.json +410 -9
- package/spec/remediation.json +196 -0
- package/spec/schemas/conformance.schema.json +80 -2
|
@@ -86,7 +86,7 @@ const C10_SUBPROCESS_METHODS = new Set(['execSync', 'execFileSync', 'spawnSync',
|
|
|
86
86
|
|
|
87
87
|
const PARSE_CACHE = new WeakMap()
|
|
88
88
|
|
|
89
|
-
function parseSource(source) {
|
|
89
|
+
export function parseSource(source) {
|
|
90
90
|
const cached = PARSE_CACHE.get(source)
|
|
91
91
|
if (cached) return cached
|
|
92
92
|
const record = { ast: null, error: null }
|
|
@@ -107,21 +107,21 @@ function parseSource(source) {
|
|
|
107
107
|
return record
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function normalizeRelative(relative) {
|
|
110
|
+
export function normalizeRelative(relative) {
|
|
111
111
|
return String(relative ?? '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
// Declaration files have no runtime behavior; parsing them for import-time
|
|
115
115
|
// side effects or execution strings only creates noise.
|
|
116
|
-
function isDeclarationFile(relative) {
|
|
116
|
+
export function isDeclarationFile(relative) {
|
|
117
117
|
return /\.d\.[cm]?ts$/i.test(String(relative ?? ''))
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
function identifierName(node) {
|
|
120
|
+
export function identifierName(node) {
|
|
121
121
|
return node?.type === 'Identifier' ? node.name : null
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
function memberPropertyName(node) {
|
|
124
|
+
export function memberPropertyName(node) {
|
|
125
125
|
if (!node || node.type !== 'MemberExpression') return null
|
|
126
126
|
if (!node.computed && node.property?.type === 'Identifier') return node.property.name
|
|
127
127
|
if (node.property?.type === 'Literal' && typeof node.property.value === 'string') return node.property.value
|
|
@@ -141,7 +141,7 @@ function calledFunctionName(call) {
|
|
|
141
141
|
return null
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
function walkAst(root, visit) {
|
|
144
|
+
export function walkAst(root, visit) {
|
|
145
145
|
const ancestors = []
|
|
146
146
|
const walkNode = (node) => {
|
|
147
147
|
if (!node || typeof node.type !== 'string') return
|
|
@@ -163,7 +163,7 @@ function walkAst(root, visit) {
|
|
|
163
163
|
|
|
164
164
|
// Module scope means "runs while the module is evaluated". Function bodies and
|
|
165
165
|
// class method bodies are skipped; top-level if/try/loop bodies are not.
|
|
166
|
-
function walkModuleScope(ast, visit) {
|
|
166
|
+
export function walkModuleScope(ast, visit) {
|
|
167
167
|
const ancestors = []
|
|
168
168
|
const walkNode = (node) => {
|
|
169
169
|
if (!node || typeof node.type !== 'string') return
|
|
@@ -295,7 +295,7 @@ function classifyLiteral(node, ancestors) {
|
|
|
295
295
|
return 'unknown'
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
function literalStringValue(node) {
|
|
298
|
+
export function literalStringValue(node) {
|
|
299
299
|
if (node.type === 'Literal' && typeof node.value === 'string') return node.value
|
|
300
300
|
if (node.type === 'TemplateElement' && node.value) {
|
|
301
301
|
if (typeof node.value.cooked === 'string') return node.value.cooked
|
|
@@ -341,7 +341,7 @@ function literalCandidates(base) {
|
|
|
341
341
|
return candidates
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
-
function resolveLocalSpecifier(fromRelative, specifier, byRelative) {
|
|
344
|
+
export function resolveLocalSpecifier(fromRelative, specifier, byRelative) {
|
|
345
345
|
const spec = String(specifier).replace(/\\/g, '/')
|
|
346
346
|
if (!(spec.startsWith('./') || spec.startsWith('../') || spec === '.' || spec === '..')) return { kind: 'package' }
|
|
347
347
|
const base = path.posix.normalize(path.posix.join(path.posix.dirname(normalizeRelative(fromRelative)), spec))
|
|
@@ -352,7 +352,7 @@ function resolveLocalSpecifier(fromRelative, specifier, byRelative) {
|
|
|
352
352
|
return { kind: 'unresolved' }
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
-
function collectModuleEdges(ast) {
|
|
355
|
+
export function collectModuleEdges(ast) {
|
|
356
356
|
const edges = []
|
|
357
357
|
walkAst(ast, (node) => {
|
|
358
358
|
if (node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') {
|
|
@@ -28,6 +28,15 @@ import fs from 'node:fs'
|
|
|
28
28
|
import os from 'node:os'
|
|
29
29
|
import path from 'node:path'
|
|
30
30
|
import { spawnSync } from 'node:child_process'
|
|
31
|
+
import { signEvidence } from './signature.mjs'
|
|
32
|
+
|
|
33
|
+
// Signing helpers live in signature.mjs; re-exported here so callers that
|
|
34
|
+
// already import the evidence model do not need a second module path.
|
|
35
|
+
export {
|
|
36
|
+
SIGNATURE_ALG, SIGNATURE_SCHEMA, canonicalJson as signatureCanonicalJson,
|
|
37
|
+
evidenceSigningPayload, generateEd25519KeyPair, keyIdFromPublicKey,
|
|
38
|
+
signEvidence, verifyEvidenceSignature,
|
|
39
|
+
} from './signature.mjs'
|
|
31
40
|
|
|
32
41
|
export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
|
|
33
42
|
export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
|
|
@@ -107,6 +116,18 @@ export function evidenceHashMatches(document) {
|
|
|
107
116
|
return declared === evidenceHash(document)
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Attach an Ed25519 signature to a flow-evidence document. The signature
|
|
121
|
+
* covers canonical JSON minus `signature` (the body plus `chain`), so it binds
|
|
122
|
+
* the declared chain hash while never changing `chain.evidenceHash`; it never
|
|
123
|
+
* upgrades a claim level. Verification is `verifyEvidenceSignature()`
|
|
124
|
+
* (re-exported from signature.mjs); structural acceptance remains
|
|
125
|
+
* `validateFlowEvidence()`.
|
|
126
|
+
*/
|
|
127
|
+
export function attachEvidenceSignature(document, privateKey, options = {}) {
|
|
128
|
+
return signEvidence(document, privateKey, options)
|
|
129
|
+
}
|
|
130
|
+
|
|
110
131
|
const DEFAULT_TREE_EXCLUDE = ['.git', 'node_modules']
|
|
111
132
|
const TREE_EXCLUDE_ALWAYS = new Set(['.git', 'node_modules'])
|
|
112
133
|
|
package/40-checker/src/index.mjs
CHANGED
|
@@ -5,7 +5,8 @@ export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdviso
|
|
|
5
5
|
export { readSources, readSourcesWithStats, listSourceFiles, scanSourceFiles } from './util.mjs'
|
|
6
6
|
export { runRuntimeChecks } from './runtime.mjs'
|
|
7
7
|
export { runStaticBundleChecks } from './bundle-static.mjs'
|
|
8
|
-
export {
|
|
8
|
+
export { runSecurityChecks, checkLifecycleScripts, checkDynamicExecution, checkSensitiveFileReads, checkEgressEndpoints, checkOpaqueArtifacts, checkPromptInjectionClues, checkSupplyChain, checkProvenance, SECURITY_IDS } from './security.mjs'
|
|
9
|
+
export { buildReport, formatText, formatSarif, writeReport, summarizeBlocking, buildSecurityView, DECLARATION_CHECKS, SECURITY_CHECKS, SECURITY_MANDATORY } from './report.mjs'
|
|
9
10
|
export { joinAdvice, DOCS_BASE } from './remediation.mjs'
|
|
10
11
|
export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
|
|
11
12
|
export { parseInstallSpec } from './install-spec.mjs'
|
|
@@ -13,8 +13,13 @@ export const ENTRY_RUN = ['DSC-01', 'DSC-02', 'DSC-03', 'DSC-11', 'DSC-12', 'DSC
|
|
|
13
13
|
export const ENTRY_BLOCKING = ['C-1.1', 'C-1.2', 'C-1.4', 'C-3.1', 'C-3.2', 'C-3.5']
|
|
14
14
|
export const ENTRY_RUNTIME_BLOCKING = [...ENTRY_BLOCKING, 'C-2.2', 'C-2.3']
|
|
15
15
|
|
|
16
|
+
/** The four security checks that block in `--scope security`; the rest are advisory. */
|
|
17
|
+
export const SECURITY_MANDATORY = ['DSC-15', 'DSC-16', 'DSC-17', 'DSC-21']
|
|
18
|
+
export const SECURITY_CHECKS = new Set(['DSC-15', 'DSC-16', 'DSC-17', 'DSC-18', 'DSC-19', 'DSC-20', 'DSC-21', 'DSC-22'])
|
|
19
|
+
|
|
16
20
|
function mandatoryFor(scope, profile, staticOnly, entryClaim) {
|
|
17
21
|
if (scope === 'mvp') return MVP_CHECKS
|
|
22
|
+
if (scope === 'security') return SECURITY_MANDATORY
|
|
18
23
|
if (scope === 'entry') return ENTRY_RUN.filter(id => (entryClaim !== 'static' || id !== 'DSC-14') && (id !== 'DSC-03' || profile === 'listing' || profile === 'both'))
|
|
19
24
|
if (staticOnly) return STATIC_MANDATORY
|
|
20
25
|
return profile === 'listing' || profile === 'both' ? LISTING_MANDATORY : PACKAGE_MANDATORY
|
|
@@ -68,6 +73,11 @@ export function howToEnableFor(item) {
|
|
|
68
73
|
if (id === 'DSC-14') {
|
|
69
74
|
return 'pass --host <app-boot/lib/index.js> for bundle G0-G3, or --runtime <path> for a plugin; omit --no-exec and --entry-claim static'
|
|
70
75
|
}
|
|
76
|
+
if (id === 'DSC-15') return 'fix package.json#scripts so install lifecycle commands are auditable; --scope security makes DSC-15 blocking'
|
|
77
|
+
if (['DSC-16', 'DSC-17', 'DSC-21'].includes(id)) return 'provide a readable entry/source tree and rerun; --scope security makes this check blocking'
|
|
78
|
+
if (id === 'DSC-19') return 'provide a readable package tree; binaries are recorded by sha256, never executed'
|
|
79
|
+
if (id === 'DSC-22') return 'supply registry metadata or an attestation/signature field; without input this check is not-run'
|
|
80
|
+
if (['DSC-18', 'DSC-20'].includes(id)) return 'this check is advisory; it never blocks and needs no extra input'
|
|
71
81
|
if (['DSC-11', 'DSC-12', 'DSC-13'].includes(id)) {
|
|
72
82
|
if (/manifest unavailable/.test(reason)) return 'fix package.json; these declaration checks cannot run without a manifest'
|
|
73
83
|
return 'run the full profile without --no-exec; declaration checks need source files'
|
|
@@ -108,8 +118,58 @@ function allowedNa(check, scope, profile, kind) {
|
|
|
108
118
|
return false
|
|
109
119
|
}
|
|
110
120
|
|
|
111
|
-
|
|
121
|
+
function capSecurityCheck(item) {
|
|
122
|
+
if (item.result !== 'FAIL') return item
|
|
123
|
+
return {
|
|
124
|
+
...item,
|
|
125
|
+
result: 'WARN',
|
|
126
|
+
reason: `capped WARN (non-security scope): ${item.reason ?? ''}`.trim(),
|
|
127
|
+
details: (item.details ?? []).map(detailRow => (
|
|
128
|
+
detailRow.result === 'FAIL'
|
|
129
|
+
? { ...detailRow, result: 'WARN', reason: `capped WARN (non-security scope): ${detailRow.reason ?? ''}`.trim() }
|
|
130
|
+
: detailRow
|
|
131
|
+
)),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The additive `security` block. In `--scope security` the eight checks keep
|
|
137
|
+
* their real verdict (and four of them are mandatory); in every other scope a
|
|
138
|
+
* FAIL is capped to WARN so the default verdict cannot change.
|
|
139
|
+
*/
|
|
140
|
+
export function buildSecurityView(securityChecks, scope) {
|
|
141
|
+
const input = Array.isArray(securityChecks) ? securityChecks.filter(item => SECURITY_CHECKS.has(item.id)) : []
|
|
142
|
+
const checks = scope === 'security' ? input : input.map(capSecurityCheck)
|
|
143
|
+
// Only DSC-15/16/17 may block; DSC-21 is mandatory but its findings are
|
|
144
|
+
// capped at WARN, and DSC-18/19/20/22 are advisory by definition.
|
|
145
|
+
const blocking = checks
|
|
146
|
+
.filter(item => item.result === 'FAIL' && ['DSC-15', 'DSC-16', 'DSC-17'].includes(item.id))
|
|
147
|
+
.map(item => item.id)
|
|
148
|
+
const warn = checks.filter(item => item.result === 'WARN' && item.advisory !== true).map(item => item.id)
|
|
149
|
+
const advisory = checks.filter(item => item.result === 'WARN' && item.advisory === true).map(item => item.id)
|
|
150
|
+
const notRun = checks
|
|
151
|
+
.filter(item => item.result === 'NA' || item.result === 'ERROR' || item.coverage === 'not-run')
|
|
152
|
+
.map(item => item.id)
|
|
153
|
+
const mandatoryNotRun = SECURITY_MANDATORY.some(id => !checks.some(item => item.id === id && ['PASS', 'WARN', 'FAIL'].includes(item.result)))
|
|
154
|
+
let status
|
|
155
|
+
if (blocking.length > 0) status = 'fail'
|
|
156
|
+
else if (scope === 'security' && mandatoryNotRun) status = 'incomplete'
|
|
157
|
+
else if (warn.length > 0 || advisory.length > 0 || mandatoryNotRun) status = 'warn'
|
|
158
|
+
else status = 'pass'
|
|
159
|
+
return {
|
|
160
|
+
scope: scope === 'security' ? 'security' : 'advisory',
|
|
161
|
+
status,
|
|
162
|
+
checks,
|
|
163
|
+
blocking,
|
|
164
|
+
warn,
|
|
165
|
+
advisory,
|
|
166
|
+
notRun,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime', advice = true, declarations = null, runtimeChecks = null, securityChecks = null }) {
|
|
112
171
|
const mandatory = mandatoryFor(scope, profile, staticOnly, entryClaim)
|
|
172
|
+
const security = buildSecurityView(securityChecks, scope)
|
|
113
173
|
const adjustedChecks = scope === 'entry' ? checks.map(item => adjustEntryCheck(item, entryClaim)) : checks
|
|
114
174
|
const runtimeIds = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
|
|
115
175
|
const effectiveChecks = staticOnly
|
|
@@ -123,7 +183,12 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
123
183
|
const missing = mandatory.filter(id => !byId.has(id))
|
|
124
184
|
const failed = selected.filter(item => item.result === 'FAIL')
|
|
125
185
|
const errored = selected.filter(item => item.result === 'ERROR')
|
|
126
|
-
|
|
186
|
+
// A static `partial` PASS is still a PASS for the mandatory set: it is
|
|
187
|
+
// recorded in buckets.partial / coverage.partial, and "what was not run" is
|
|
188
|
+
// its own signal. Only a check that did not run at all makes the report
|
|
189
|
+
// incomplete. Otherwise every bundle would be exit 2 forever, because the
|
|
190
|
+
// static subset can never be full without executing the package.
|
|
191
|
+
const notRun = selected.filter(item => !allowedNa(item, scope, profile, kind) && item.coverage === 'not-run')
|
|
127
192
|
const notRunIds = [...new Set([...missing, ...errored.map(item => item.id), ...notRun.map(item => item.id)])]
|
|
128
193
|
const warnings = selected.filter(item => item.result === 'WARN')
|
|
129
194
|
const pass = selected.filter(item => item.result === 'PASS')
|
|
@@ -134,7 +199,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
134
199
|
const evaluatedAll = effectiveChecks.filter(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
135
200
|
const naAll = effectiveChecks.filter(item => item.result === 'NA')
|
|
136
201
|
const errorAll = effectiveChecks.filter(item => item.result === 'ERROR')
|
|
137
|
-
const notRunAll = effectiveChecks.filter(item => item.result !== 'NA' &&
|
|
202
|
+
const notRunAll = effectiveChecks.filter(item => item.result !== 'NA' && item.coverage === 'not-run')
|
|
138
203
|
const notRunAllIds = [...new Set([...notRunAll.map(item => item.id), ...errorAll.map(item => item.id), ...missing])]
|
|
139
204
|
const entryBlockingIds = scope === 'entry' ? (entryClaim === 'static' ? ENTRY_BLOCKING : ENTRY_RUNTIME_BLOCKING) : null
|
|
140
205
|
const entryBlocking = entryBlockingIds ? summarizeBlocking(effectiveChecks, entryBlockingIds) : null
|
|
@@ -156,7 +221,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
156
221
|
} else if (status === 'pass' || status === 'warn') {
|
|
157
222
|
level = `DSH-ENTRY static${status === 'warn' ? ' (warnings)' : ''}`
|
|
158
223
|
}
|
|
159
|
-
} else if (scope !== 'mvp' && (status === 'pass' || status === 'warn')) {
|
|
224
|
+
} else if (scope !== 'mvp' && scope !== 'security' && (status === 'pass' || status === 'warn')) {
|
|
160
225
|
const suffix = status === 'warn' ? ', warnings' : ''
|
|
161
226
|
level = staticOnly ? `DS-C static${status === 'warn' ? ' (warnings)' : ''}` : profile === 'listing' || profile === 'both' ? `DS-C (listing${suffix})` : `DS-C (package${suffix})`
|
|
162
227
|
}
|
|
@@ -176,6 +241,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
176
241
|
const hygieneBucket = effectiveChecks.filter(item => item.result === 'WARN').map(item => bucketRow(item, {
|
|
177
242
|
declaration: DECLARATION_CHECKS.has(item.id),
|
|
178
243
|
advisory: item.advisory === true,
|
|
244
|
+
...(SECURITY_CHECKS.has(item.id) ? { security: true } : {}),
|
|
179
245
|
}))
|
|
180
246
|
const notCheckedBucket = effectiveChecks
|
|
181
247
|
.filter(item => item.result === 'NA' || item.result === 'ERROR')
|
|
@@ -235,6 +301,7 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
235
301
|
entryBlocking,
|
|
236
302
|
},
|
|
237
303
|
buckets,
|
|
304
|
+
security,
|
|
238
305
|
notChecked,
|
|
239
306
|
metrics,
|
|
240
307
|
conclusion: {
|
|
@@ -251,6 +318,11 @@ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile
|
|
|
251
318
|
reason: reasonParts.join('; '),
|
|
252
319
|
},
|
|
253
320
|
}
|
|
321
|
+
// Additive only: describes what actually executed (runtime/host), the
|
|
322
|
+
// elapsed time, and the isolation it used. It is a sibling of `checks` and
|
|
323
|
+
// never changes the verdict. Kept out of the 1.0.0 schema shape and only
|
|
324
|
+
// emitted when a runtime/host run was requested.
|
|
325
|
+
if (runtimeChecks) report.runtimeChecks = runtimeChecks
|
|
254
326
|
// Additive only: `advice` is a sibling of `checks`, never part of a check,
|
|
255
327
|
// and the verdict is computed above without reading it.
|
|
256
328
|
if (advice !== false) {
|
|
@@ -302,6 +374,41 @@ function renderBucket(lines, title, rows, byId) {
|
|
|
302
374
|
}
|
|
303
375
|
}
|
|
304
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Runtime provenance: what actually ran, for how long, and where. A failed
|
|
379
|
+
* run must say why instead of silently falling back to static rows.
|
|
380
|
+
*/
|
|
381
|
+
function renderRuntime(lines, runtime) {
|
|
382
|
+
if (!runtime) return
|
|
383
|
+
if (!runtime.executed) {
|
|
384
|
+
// Do not repeat six not-run rows that the partial/not-checked buckets
|
|
385
|
+
// already explain; one line is enough to say nothing executed.
|
|
386
|
+
lines.push(`Runtime checks (not executed): ${runtime.reason ?? runtime.failure ?? 'no runtime requested'}`)
|
|
387
|
+
lines.push('')
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
const modes = Array.isArray(runtime.modes) && runtime.modes.length > 0 ? runtime.modes.join('+') : 'none'
|
|
391
|
+
const elapsed = Number.isFinite(runtime.durationMs) ? `${runtime.durationMs} ms` : 'n/a'
|
|
392
|
+
const isolation = runtime.isolation?.isolatedHome ? 'temporary DSH_HOME' : 'un-isolated'
|
|
393
|
+
lines.push(`Runtime checks (${modes}, ${elapsed}, ${isolation}):`)
|
|
394
|
+
if (runtime.failure) lines.push(` failure: ${String(runtime.failure).replace(/\s+/g, ' ').slice(0, 200)}`)
|
|
395
|
+
const rows = Array.isArray(runtime.checks) ? runtime.checks : []
|
|
396
|
+
if (rows.length === 0) {
|
|
397
|
+
lines.push(' none')
|
|
398
|
+
} else {
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
const reason = String(row.reason ?? '').replace(/\s+/g, ' ').slice(0, 140)
|
|
401
|
+
lines.push(` ${(ICON[row.result] ?? row.result).padEnd(4)} ${String(row.id).padEnd(7)} ${String(row.coverage ?? '-').padEnd(8)} ${reason}`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const entry = runtime.entry
|
|
405
|
+
if (entry) {
|
|
406
|
+
const reason = String(entry.reason ?? '').replace(/\s+/g, ' ').slice(0, 140)
|
|
407
|
+
lines.push(` ${(ICON[entry.result] ?? entry.result).padEnd(4)} ${String(entry.id).padEnd(7)} ${String(entry.coverage ?? '-').padEnd(8)} ${reason}`)
|
|
408
|
+
}
|
|
409
|
+
lines.push('')
|
|
410
|
+
}
|
|
411
|
+
|
|
305
412
|
export function formatText(report) {
|
|
306
413
|
const lines = []
|
|
307
414
|
const { subject, anchor = {}, conclusion, coverage } = report
|
|
@@ -318,11 +425,16 @@ export function formatText(report) {
|
|
|
318
425
|
lines.push('')
|
|
319
426
|
renderBucket(lines, 'Partly checked (static subset)', report.buckets?.partial ?? [], byId)
|
|
320
427
|
lines.push('')
|
|
428
|
+
renderRuntime(lines, report.runtimeChecks)
|
|
321
429
|
renderBucket(lines, 'Not checked (NA / not-run / ERROR)', report.buckets?.notChecked ?? [], byId)
|
|
322
430
|
lines.push('')
|
|
323
431
|
const passed = (report.checks ?? []).filter(item => item.result === 'PASS')
|
|
324
432
|
lines.push(`passed (${passed.length}): ${passed.map(item => item.id).join(', ') || 'none'}`)
|
|
325
433
|
lines.push(`coverage: total=${coverage.total ?? report.checks.length} checked=${coverage.checked ?? coverage.pass + coverage.warn + coverage.fail} pass=${coverage.pass} warn=${coverage.warn} fail=${coverage.fail} na=${coverage.na} notRun=${coverage.notRun} partial=${coverage.partial ?? 0} error=${coverage.error} advisory=${coverage.advisory}`)
|
|
434
|
+
if (report.security) {
|
|
435
|
+
const security = report.security
|
|
436
|
+
lines.push(`security: scope=${security.scope} status=${security.status} blocking=[${(security.blocking ?? []).join(', ')}] warn=[${(security.warn ?? []).join(', ')}] advisory=[${(security.advisory ?? []).join(', ')}] notRun=[${(security.notRun ?? []).join(', ')}]`)
|
|
437
|
+
}
|
|
326
438
|
if (coverage.entryBlocking) {
|
|
327
439
|
const blocking = coverage.entryBlocking
|
|
328
440
|
lines.push(`entryBlocking: checked=${blocking.checked?.length ?? '?'}/${blocking.required} failed=[${(blocking.failed ?? []).join(', ')}] notChecked=[${(blocking.notChecked ?? []).join(', ')}] pass=${blocking.pass}`)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs L1-L3 in a disposable child process so a plugin cannot block or take over
|
|
3
|
+
// the checker. Input: JSON on stdin. Output: one marker line + JSON on fd 3.
|
|
4
|
+
//
|
|
5
|
+
// The child is *expected to be hostile*: a plugin may leave timers, sockets or
|
|
6
|
+
// workers referenced after `dispose()`. Waiting for the process to exit then
|
|
7
|
+
// looks like a hang even though every check finished, so the result is written
|
|
8
|
+
// first and the process is terminated in the write callback. The parent also
|
|
9
|
+
// resolves on the marker line, so a plugin that keeps the event loop alive
|
|
10
|
+
// cannot turn a completed run into "killed after timeout".
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import { runRuntimeChecks } from './runtime.mjs'
|
|
13
|
+
import { readSources } from './util.mjs'
|
|
14
|
+
import { scrubProcessEnv } from './sandbox-env.mjs'
|
|
15
|
+
|
|
16
|
+
export const MARKER = '__DSH_RUNTIME_CHECKS__'
|
|
17
|
+
|
|
18
|
+
const chunks = []
|
|
19
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
20
|
+
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
21
|
+
|
|
22
|
+
// This process imports the inspected package; keep only allowlisted env keys,
|
|
23
|
+
// then apply the caller-supplied disposable HOME/DSH_HOME/TMPDIR. DSH_HOME is
|
|
24
|
+
// deliberately not allowlisted: an inherited real profile must never reach a
|
|
25
|
+
// plugin, while an explicit temp directory from the checker is trusted.
|
|
26
|
+
scrubProcessEnv()
|
|
27
|
+
const overrides = {
|
|
28
|
+
DSH_HOME: input.home,
|
|
29
|
+
HOME: input.home,
|
|
30
|
+
USERPROFILE: input.home,
|
|
31
|
+
TMPDIR: input.tmp,
|
|
32
|
+
TEMP: input.tmp,
|
|
33
|
+
TMP: input.tmp,
|
|
34
|
+
XDG_CONFIG_HOME: input.home ? `${input.home}/.config` : undefined,
|
|
35
|
+
XDG_DATA_HOME: input.home ? `${input.home}/.local/share` : undefined,
|
|
36
|
+
XDG_CACHE_HOME: input.home ? `${input.home}/.cache` : undefined,
|
|
37
|
+
}
|
|
38
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
39
|
+
if (typeof value === 'string' && value !== '') process.env[key] = value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const started = process.hrtime.bigint()
|
|
43
|
+
let payload
|
|
44
|
+
try {
|
|
45
|
+
let sources = []
|
|
46
|
+
try { sources = readSources(input.dir) } catch { sources = [] }
|
|
47
|
+
const checks = await runRuntimeChecks({ ...input, sources })
|
|
48
|
+
payload = { checks, durationMs: Number((process.hrtime.bigint() - started) / 1_000_000n), error: null }
|
|
49
|
+
} catch (error) {
|
|
50
|
+
payload = {
|
|
51
|
+
checks: [],
|
|
52
|
+
durationMs: Number((process.hrtime.bigint() - started) / 1_000_000n),
|
|
53
|
+
error: String(error?.stack ?? error?.message ?? error).slice(0, 2000),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const line = `${MARKER}${JSON.stringify(payload)}\n`
|
|
58
|
+
// The plugin shares stdout, so results go to a dedicated pipe (fd 3) that a
|
|
59
|
+
// plugin printing logs cannot interleave with or truncate.
|
|
60
|
+
const writeResult = (callback) => {
|
|
61
|
+
try { fs.writeSync(3, line) } catch {
|
|
62
|
+
// A direct `node src/runtime-child.mjs` has no fd 3: fall back to stdout.
|
|
63
|
+
process.stdout.write(line, callback)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
callback()
|
|
67
|
+
}
|
|
68
|
+
writeResult(() => process.exit(0))
|
|
@@ -193,7 +193,9 @@ export async function runRuntimeChecks(input) {
|
|
|
193
193
|
const provideResult = declared.length === 0 ? 'NA' : worst(provideDetails.map(item => item.result))
|
|
194
194
|
const provideCheck = check({
|
|
195
195
|
id: 'DSC-05', clause: 'C-5', layer: 'L1', result: provideResult,
|
|
196
|
-
|
|
196
|
+
// NA is a real finding too ("nothing declared"), so keep its reason; an
|
|
197
|
+
// empty reason here used to make DSC-05 look unexplained in the report.
|
|
198
|
+
reason: provideDetails.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
|
|
197
199
|
details: provideDetails,
|
|
198
200
|
})
|
|
199
201
|
|