dsh-standards 1.5.0 → 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/src/cli.mjs +8 -3
- package/40-checker/src/declarations.mjs +10 -10
- package/40-checker/src/index.mjs +2 -1
- package/40-checker/src/report.mjs +68 -2
- package/40-checker/src/security.mjs +1065 -0
- package/40-checker/src/util.mjs +53 -0
- package/CHANGELOG.md +14 -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 +75 -2
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security layer v1: DSC-15…DSC-22.
|
|
3
|
+
*
|
|
4
|
+
* These checks are static clues, not a security audit. They cover:
|
|
5
|
+
* 15 install lifecycle scripts (blocking in --scope security)
|
|
6
|
+
* 16 dynamic execution (blocking)
|
|
7
|
+
* 17 sensitive-file reads (blocking)
|
|
8
|
+
* 18 egress endpoints (advisory)
|
|
9
|
+
* 19 opaque/binary artifacts (advisory)
|
|
10
|
+
* 20 prompt-injection clues (advisory)
|
|
11
|
+
* 21 supply-chain name similarity (blocking in --scope security, WARN only)
|
|
12
|
+
* 22 provenance/integrity (advisory, not-run without metadata)
|
|
13
|
+
*
|
|
14
|
+
* None of these checks can prove the absence of malicious code.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
import { check, detail, worst } from './result.mjs'
|
|
20
|
+
import { inventoryFiles } from './util.mjs'
|
|
21
|
+
import {
|
|
22
|
+
collectModuleEdges,
|
|
23
|
+
identifierName,
|
|
24
|
+
isDeclarationFile,
|
|
25
|
+
literalStringValue,
|
|
26
|
+
memberPropertyName,
|
|
27
|
+
normalizeRelative,
|
|
28
|
+
parseSource,
|
|
29
|
+
resolveLocalSpecifier,
|
|
30
|
+
walkAst,
|
|
31
|
+
} from './declarations.mjs'
|
|
32
|
+
|
|
33
|
+
export const SECURITY_IDS = [
|
|
34
|
+
'DSC-15', 'DSC-16', 'DSC-17', 'DSC-18', 'DSC-19', 'DSC-20', 'DSC-21', 'DSC-22',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
const SECURITY_ADVISORY = new Set(['DSC-18', 'DSC-19', 'DSC-20', 'DSC-22'])
|
|
38
|
+
const SECURITY_CLAUSE = {
|
|
39
|
+
'DSC-15': 'C-15', 'DSC-16': 'C-16', 'DSC-17': 'C-17', 'DSC-18': 'C-18',
|
|
40
|
+
'DSC-19': 'C-19', 'DSC-20': 'C-20', 'DSC-21': 'C-21', 'DSC-22': 'C-22',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const READ_APIS = new Set([
|
|
44
|
+
'readFile', 'readFileSync', 'readJson', 'readJsonSync', 'readYaml', 'readYamlSync',
|
|
45
|
+
'open', 'openSync', 'createReadStream', 'readdir', 'readdirSync',
|
|
46
|
+
])
|
|
47
|
+
const CHILD_PROCESS = /^(?:node:)?child_process$/
|
|
48
|
+
const INSTRUCTION_PATTERN = /\b(?:ignore|disregard|forget)\b[^\n]{0,60}\b(?:previous|prior|above|earlier|system)\b|\byou are now\b|\bdo not (?:tell|reveal|mention|disclose)\b|\bsystem prompt\b|\bact as\b/i
|
|
49
|
+
const SUSPICIOUS_HOST = /(?:^|\.)(?:webhook|pastebin|paste|ngrok\.io|ngrok-free\.app|requestbin|pipedream|interact\.sh|burpcollaborator|canarytokens)\./i
|
|
50
|
+
const TEXT_EXTENSIONS = /\.(?:[cm]?[jt]sx?|json|md|txt|ya?ml|html|css)$/i
|
|
51
|
+
const BINARY_EXTENSIONS = new Set(['.wasm', '.node', '.so', '.dll', '.exe', '.dylib'])
|
|
52
|
+
const RULE_FILE = /(?:^|\/)(?:rules?|guards?|defend|detect|policy|policies|patterns?)(?:[./-]|$)/i
|
|
53
|
+
const RULE_KEYS = new Set(['rules', 'rule', 'patterns', 'pattern', 'regex', 'regexes', 'guard', 'guards', 'deny', 'blocklist', 'allowlist'])
|
|
54
|
+
const DOC_KEYS = new Set(['desc', 'description', 'message', 'title', 'label', 'reason', 'comment', 'help'])
|
|
55
|
+
|
|
56
|
+
const LIFECYCLE_NAMES = ['preinstall', 'install', 'postinstall', 'prepare']
|
|
57
|
+
const DANGEROUS_SCRIPT = [
|
|
58
|
+
{ pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/, label: 'remote script piped to a shell' },
|
|
59
|
+
{ pattern: /\bbase64\s+(?:-d|--decode)\b|\bbase64\s+-D\b/, label: 'base64 decode in an install script' },
|
|
60
|
+
{ pattern: /\bnode\s+(?:-e|--eval)\b|\bnode(?:\.exe)?\s+['"]?-e/, label: 'node -e in an install script' },
|
|
61
|
+
{ pattern: /\b(?:sh|bash|zsh|dash|ksh)\s+-[a-z]*c\b/, label: 'shell -c in an install script' },
|
|
62
|
+
{ pattern: /\bpowershell(?:\.exe)?\b[^\n]*(?:-enc|-encodedcommand)\b/i, label: 'powershell encoded command' },
|
|
63
|
+
{ pattern: /\$HOME\b|\$\{HOME\}|\$DSH_HOME\b|\$\{DSH_HOME\}/, label: 'install script references the home directory' },
|
|
64
|
+
{ pattern: /(?:^|[\s'"=:(])~\/|(?:^|[\s'"=:(])\/etc\//, label: 'install script references ~ or /etc' },
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// small AST helpers shared by the checks below
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
function keyName(property) {
|
|
72
|
+
if (!property || property.type !== 'Property') return null
|
|
73
|
+
if (!property.computed && property.key?.type === 'Identifier') return property.key.name
|
|
74
|
+
if (property.key?.type === 'Literal' && typeof property.key.value === 'string') return property.key.value
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function chainNames(node) {
|
|
79
|
+
const names = []
|
|
80
|
+
let current = node
|
|
81
|
+
for (let guard = 0; current && guard < 12; guard += 1) {
|
|
82
|
+
if (current.type === 'Identifier') { names.unshift(current.name); break }
|
|
83
|
+
if (current.type === 'MemberExpression') {
|
|
84
|
+
const property = memberPropertyName(current)
|
|
85
|
+
if (property === null) break
|
|
86
|
+
names.unshift(property)
|
|
87
|
+
current = current.object
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (current.type === 'CallExpression' || current.type === 'NewExpression') {
|
|
91
|
+
current = current.callee
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
return names
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isClientRelative(relative) {
|
|
100
|
+
return /(?:^|\/)(?:client|browser)(?:\.(?:[cm]?[jt]sx?)|(?:\/))/i.test(normalizeRelative(relative))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function serverSources(sources) {
|
|
104
|
+
return (sources ?? []).filter(source => !isDeclarationFile(source.relative) && !isClientRelative(source.relative))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function lineOf(node) {
|
|
108
|
+
return node?.loc?.start?.line ?? 1
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function evidenceOf(file, node) {
|
|
112
|
+
return `${normalizeRelative(file)}:${lineOf(node)}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function collectBindings(ast) {
|
|
116
|
+
const bindings = new Map()
|
|
117
|
+
walkAst(ast, (node) => {
|
|
118
|
+
if (node.type !== 'VariableDeclarator') return
|
|
119
|
+
if (node.id?.type !== 'Identifier' || !node.init) return
|
|
120
|
+
if (!bindings.has(node.id.name)) bindings.set(node.id.name, node.init)
|
|
121
|
+
})
|
|
122
|
+
return bindings
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stringFromNode(node, bindings, depth = 0, seen = new Set()) {
|
|
126
|
+
if (!node || depth > 5 || seen.has(node)) return null
|
|
127
|
+
if (node.type === 'Literal' && typeof node.value === 'string') return node.value
|
|
128
|
+
if (node.type === 'TemplateLiteral') {
|
|
129
|
+
let text = ''
|
|
130
|
+
for (let index = 0; index < node.quasis.length; index += 1) {
|
|
131
|
+
text += node.quasis[index].value?.cooked ?? node.quasis[index].value?.raw ?? ''
|
|
132
|
+
if (index < node.expressions.length) {
|
|
133
|
+
const part = stringFromNode(node.expressions[index], bindings, depth + 1, new Set(seen).add(node))
|
|
134
|
+
if (part === null) return null
|
|
135
|
+
text += part
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return text
|
|
139
|
+
}
|
|
140
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
141
|
+
const left = stringFromNode(node.left, bindings, depth + 1, new Set(seen).add(node))
|
|
142
|
+
const right = stringFromNode(node.right, bindings, depth + 1, new Set(seen).add(node))
|
|
143
|
+
if (left === null || right === null) return null
|
|
144
|
+
return left + right
|
|
145
|
+
}
|
|
146
|
+
if (node.type === 'ConditionalExpression') {
|
|
147
|
+
return stringFromNode(node.consequent, bindings, depth + 1, new Set(seen).add(node))
|
|
148
|
+
?? stringFromNode(node.alternate, bindings, depth + 1, new Set(seen).add(node))
|
|
149
|
+
}
|
|
150
|
+
if (node.type === 'LogicalExpression') {
|
|
151
|
+
return stringFromNode(node.left, bindings, depth + 1, new Set(seen).add(node))
|
|
152
|
+
?? stringFromNode(node.right, bindings, depth + 1, new Set(seen).add(node))
|
|
153
|
+
}
|
|
154
|
+
if (node.type === 'Identifier') {
|
|
155
|
+
if (node.name === 'HOME') return '/home/dsh-user'
|
|
156
|
+
const init = bindings.get(node.name)
|
|
157
|
+
if (!init) return null
|
|
158
|
+
return stringFromNode(init, bindings, depth + 1, new Set(seen).add(node))
|
|
159
|
+
}
|
|
160
|
+
if (node.type === 'MemberExpression') {
|
|
161
|
+
const property = memberPropertyName(node)
|
|
162
|
+
const objectName = identifierName(node.object)
|
|
163
|
+
if (objectName === 'process' && property === 'cwd') return '/work/package'
|
|
164
|
+
if (objectName === 'process' && node.object.type === 'MemberExpression'
|
|
165
|
+
&& memberPropertyName(node.object) === 'env'
|
|
166
|
+
&& ['HOME', 'USERPROFILE'].includes(property)) return '/home/dsh-user'
|
|
167
|
+
if (objectName === 'process' && node.object.type === 'MemberExpression'
|
|
168
|
+
&& memberPropertyName(node.object) === 'env'
|
|
169
|
+
&& ['DSH_HOME', 'XDG_CONFIG_HOME'].includes(property)) return '/home/dsh-user/.dsh'
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
if (node.type === 'CallExpression') {
|
|
173
|
+
const names = chainNames(node.callee)
|
|
174
|
+
const last = names[names.length - 1]
|
|
175
|
+
if (last === 'cwd') return '/work/package'
|
|
176
|
+
if (last === 'homedir') return '/home/dsh-user'
|
|
177
|
+
if (['join', 'resolve', 'normalize'].includes(last)) {
|
|
178
|
+
const object = node.callee.type === 'MemberExpression' ? node.callee.object : null
|
|
179
|
+
const fromArray = object?.type === 'ArrayExpression' ? object.elements : null
|
|
180
|
+
if (fromArray) {
|
|
181
|
+
const parts = fromArray.map(item => stringFromNode(item, bindings, depth + 1, new Set(seen).add(node)))
|
|
182
|
+
if (parts.some(part => part === null)) return null
|
|
183
|
+
return parts.join('/')
|
|
184
|
+
}
|
|
185
|
+
const parts = node.arguments.map(item => stringFromNode(item, bindings, depth + 1, new Set(seen).add(node)))
|
|
186
|
+
if (parts.length === 0 || parts.some(part => part === null)) return null
|
|
187
|
+
return parts.join('/')
|
|
188
|
+
}
|
|
189
|
+
if (last === 'join' && node.callee.type === 'MemberExpression' && node.callee.object.type === 'ArrayExpression') {
|
|
190
|
+
const parts = node.callee.object.elements.map(item => stringFromNode(item, bindings, depth + 1, new Set(seen).add(node)))
|
|
191
|
+
if (parts.some(part => part === null)) return null
|
|
192
|
+
const separator = node.arguments.length > 0 ? stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node)) : '/'
|
|
193
|
+
return parts.join(separator ?? '/')
|
|
194
|
+
}
|
|
195
|
+
if (last === 'toString') {
|
|
196
|
+
const object = node.callee.type === 'MemberExpression' ? node.callee.object : null
|
|
197
|
+
if (object?.type === 'Literal') return String(object.value)
|
|
198
|
+
}
|
|
199
|
+
if (names[0] === 'String') return stringFromNode(node.arguments[0], bindings, depth + 1, new Set(seen).add(node))
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseModule(source) {
|
|
206
|
+
const parsed = parseSource(source)
|
|
207
|
+
if (parsed.error) return { ast: null, error: parsed.error, bindings: new Map() }
|
|
208
|
+
return { ast: parsed.ast, error: null, bindings: collectBindings(parsed.ast) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// import-closure walker (DSC-16 only; DSC-10 has its own side-effect walker)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
function analyseClosure(sources, entryRelative) {
|
|
216
|
+
const byRelative = new Map()
|
|
217
|
+
for (const source of serverSources(sources)) byRelative.set(normalizeRelative(source.relative), source)
|
|
218
|
+
const entry = entryRelative ? normalizeRelative(entryRelative) : null
|
|
219
|
+
const queue = []
|
|
220
|
+
const notes = []
|
|
221
|
+
if (entry && byRelative.has(entry)) {
|
|
222
|
+
queue.push(entry)
|
|
223
|
+
} else if (entry) {
|
|
224
|
+
notes.push({ item: 'C-16.4', result: 'NA', reason: `not-run: entry module ${entryRelative} is not a scanned server source; scanned every server source instead`, evidence: [normalizeRelative(entryRelative)] })
|
|
225
|
+
for (const relative of byRelative.keys()) queue.push(relative)
|
|
226
|
+
} else {
|
|
227
|
+
notes.push({ item: 'C-16.4', result: 'NA', reason: 'not-run: no resolvable entry; scanned every server source instead', evidence: [] })
|
|
228
|
+
for (const relative of byRelative.keys()) queue.push(relative)
|
|
229
|
+
}
|
|
230
|
+
const visited = new Set()
|
|
231
|
+
const parseErrors = []
|
|
232
|
+
const packageImports = []
|
|
233
|
+
const unresolved = []
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const relative = queue.shift()
|
|
236
|
+
if (visited.has(relative)) continue
|
|
237
|
+
visited.add(relative)
|
|
238
|
+
const parsed = parseModule(byRelative.get(relative))
|
|
239
|
+
if (parsed.error) {
|
|
240
|
+
parseErrors.push({ file: relative, ...parsed.error })
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
for (const edge of collectModuleEdges(parsed.ast)) {
|
|
244
|
+
const resolution = resolveLocalSpecifier(relative, edge.specifier, byRelative)
|
|
245
|
+
if (resolution.kind === 'local') {
|
|
246
|
+
if (!isClientRelative(resolution.relative)) queue.push(resolution.relative)
|
|
247
|
+
} else if (resolution.kind === 'package') {
|
|
248
|
+
packageImports.push({ file: relative, line: edge.line, specifier: edge.specifier })
|
|
249
|
+
} else {
|
|
250
|
+
unresolved.push({ file: relative, line: edge.line, specifier: edge.specifier })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { visited, parseErrors, packageImports, unresolved, notes, byRelative }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// DSC-15: install lifecycle scripts
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
function scriptReferenceProblems(name, command, dir, scripts) {
|
|
262
|
+
const problems = []
|
|
263
|
+
const missing = []
|
|
264
|
+
const checkRef = (reference) => {
|
|
265
|
+
if (!reference || reference.startsWith('-')) return
|
|
266
|
+
if (!/^(?:\.{0,2}\/|[\w@.-]+\/)/.test(reference) && !/\.(?:[cm]?[jt]s|sh|ps1)$/.test(reference)) return
|
|
267
|
+
const absolute = path.resolve(dir, reference)
|
|
268
|
+
if (!fs.existsSync(absolute)) missing.push(reference)
|
|
269
|
+
}
|
|
270
|
+
for (const match of command.matchAll(/\bnode\s+(?!-e\b|--eval\b)([^\s;&|]+)/g)) checkRef(match[1])
|
|
271
|
+
for (const match of command.matchAll(/\b(?:npm|pnpm|yarn)\s+run\s+([\w:.@-]+)/g)) {
|
|
272
|
+
if (!Object.hasOwn(scripts, match[1])) missing.push(`npm run ${match[1]}`)
|
|
273
|
+
}
|
|
274
|
+
if (missing.length > 0) problems.push(`scripts.${name} references missing script(s): ${[...new Set(missing)].join(', ')}`)
|
|
275
|
+
return problems
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function checkLifecycleScripts(manifest, dir) {
|
|
279
|
+
const scripts = manifest?.scripts && typeof manifest.scripts === 'object' && !Array.isArray(manifest.scripts) ? manifest.scripts : {}
|
|
280
|
+
const details = []
|
|
281
|
+
const findings = []
|
|
282
|
+
let present = 0
|
|
283
|
+
for (const name of LIFECYCLE_NAMES) {
|
|
284
|
+
const command = scripts[name]
|
|
285
|
+
if (typeof command !== 'string' || command.trim() === '') continue
|
|
286
|
+
present += 1
|
|
287
|
+
const evidence = [`package.json#scripts.${name}`]
|
|
288
|
+
const dangerous = DANGEROUS_SCRIPT.filter(rule => rule.pattern.test(command)).map(rule => rule.label)
|
|
289
|
+
const missing = scriptReferenceProblems(name, command, dir, scripts)
|
|
290
|
+
if (name === 'prepare' && dangerous.length > 0) {
|
|
291
|
+
details.push(detail('C-15.1', 'WARN', `scripts.prepare uses a dangerous form (${dangerous.join('; ')}); prepare is allowBuilds-gated, so this is WARN not FAIL`, evidence))
|
|
292
|
+
findings.push('warn')
|
|
293
|
+
} else if (dangerous.length > 0) {
|
|
294
|
+
details.push(detail('C-15.1', 'FAIL', `scripts.${name} uses a dangerous form: ${dangerous.join('; ')}`, evidence))
|
|
295
|
+
findings.push('fail')
|
|
296
|
+
} else if (missing.length > 0) {
|
|
297
|
+
// `prepare` also runs for git/source installs, which pnpm gates behind
|
|
298
|
+
// allowBuilds; a missing prepare target is therefore WARN, not FAIL.
|
|
299
|
+
const level = name === 'prepare' ? 'WARN' : 'FAIL'
|
|
300
|
+
details.push(detail('C-15.3', level, `${missing.join('; ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
|
|
301
|
+
findings.push(level === 'WARN' ? 'warn' : 'fail')
|
|
302
|
+
} else {
|
|
303
|
+
details.push(detail('C-15.2', 'WARN', `lifecycle script scripts.${name} is present; install-time commands are not executed by the checker`, evidence))
|
|
304
|
+
findings.push('warn')
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (present === 0) {
|
|
308
|
+
details.push(detail('C-15.2', 'PASS', 'no preinstall/install/postinstall/prepare script is declared'))
|
|
309
|
+
}
|
|
310
|
+
const result = worst(findings.map(value => value === 'fail' ? 'FAIL' : 'WARN'))
|
|
311
|
+
return check({
|
|
312
|
+
id: 'DSC-15',
|
|
313
|
+
clause: 'C-15',
|
|
314
|
+
layer: 'L0',
|
|
315
|
+
result: findings.length === 0 ? 'PASS' : result,
|
|
316
|
+
coverage: 'full',
|
|
317
|
+
reason: details.filter(item => item.result === 'FAIL' || item.result === 'WARN').map(item => `${item.item}: ${item.reason}`).join(' | '),
|
|
318
|
+
details,
|
|
319
|
+
evidence: ['package.json#scripts'],
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// DSC-16: dynamic execution in the server entry closure
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
function allowsEvalContext(ancestors) {
|
|
328
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
329
|
+
const node = ancestors[index]
|
|
330
|
+
if (node.type === 'Property' && keyName(node) === 'allowsEval') return true
|
|
331
|
+
if (node.type === 'VariableDeclarator' && identifierName(node.id) === 'allowsEval') return true
|
|
332
|
+
if (node.type === 'AssignmentExpression' && memberPropertyName(node.left) === 'allowsEval') return true
|
|
333
|
+
}
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function isFunctionConstructor(node) {
|
|
338
|
+
if (node.type !== 'NewExpression' && node.type !== 'CallExpression') return false
|
|
339
|
+
return chainNames(node.callee).join('.') === 'Function'
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function functionConstructorExempt(node, ancestors) {
|
|
343
|
+
const args = node.arguments ?? []
|
|
344
|
+
if (args.length === 0) return true
|
|
345
|
+
if (args.every(arg => arg.type === 'Literal' && arg.value === '')) return true
|
|
346
|
+
return allowsEvalContext(ancestors)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function checkDynamicExecution(manifest, sources, entry, kind = null) {
|
|
350
|
+
if (kind === 'client') {
|
|
351
|
+
return check({
|
|
352
|
+
id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
|
|
353
|
+
reason: 'client-only package; server entry closure is not applicable',
|
|
354
|
+
details: [detail('C-16.4', 'NA', 'not-run: client package has no server entry', ['package.json#dsh.kind'])],
|
|
355
|
+
evidence: ['package.json#dsh.kind'],
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
if (!sources || sources.length === 0) {
|
|
359
|
+
return check({
|
|
360
|
+
id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
|
|
361
|
+
reason: 'no source files scanned; DSC-16 not-run',
|
|
362
|
+
details: [detail('C-16.4', 'NA', 'not-run: no source files', [])],
|
|
363
|
+
evidence: [],
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
if (serverSources(sources).length === 0) {
|
|
367
|
+
return check({
|
|
368
|
+
id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
|
|
369
|
+
reason: 'only client/browser sources were scanned; DSC-16 does not scan client code',
|
|
370
|
+
details: [detail('C-16.4', 'NA', 'not-run: no server source outside client/browser paths', [])],
|
|
371
|
+
evidence: [],
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
const closure = analyseClosure(sources, entry?.relative ?? null)
|
|
375
|
+
const details = [...closure.notes]
|
|
376
|
+
const dynamic = []
|
|
377
|
+
const subprocess = []
|
|
378
|
+
for (const relative of closure.visited) {
|
|
379
|
+
const parsed = parseModule(closure.byRelative.get(relative))
|
|
380
|
+
if (parsed.error) continue
|
|
381
|
+
walkAst(parsed.ast, (node, ancestors) => {
|
|
382
|
+
if (node.type === 'ImportDeclaration' && typeof node.source?.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
|
|
383
|
+
subprocess.push({ file: relative, line: lineOf(node), what: `import ${node.source.value}` })
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'require') {
|
|
387
|
+
const argument = node.arguments[0]
|
|
388
|
+
if (argument?.type === 'Literal' && typeof argument.value === 'string' && CHILD_PROCESS.test(argument.value)) {
|
|
389
|
+
subprocess.push({ file: relative, line: lineOf(node), what: `require('${argument.value}')` })
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (node.type === 'ImportExpression' && node.source?.type === 'Literal' && typeof node.source.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
|
|
393
|
+
subprocess.push({ file: relative, line: lineOf(node), what: `import('${node.source.value}')` })
|
|
394
|
+
}
|
|
395
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'eval') {
|
|
396
|
+
dynamic.push({ file: relative, line: lineOf(node), what: 'eval()', item: 'C-16.1' })
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
if (isFunctionConstructor(node)) {
|
|
400
|
+
if (!functionConstructorExempt(node, ancestors)) dynamic.push({ file: relative, line: lineOf(node), what: 'new Function()', item: 'C-16.1' })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
if (node.type === 'CallExpression' || node.type === 'NewExpression') {
|
|
404
|
+
const names = chainNames(node.callee)
|
|
405
|
+
const joined = names.join('.')
|
|
406
|
+
if (/^vm\.(?:runInContext|runInNewContext|runInThisContext|compileFunction|createContext|Script)$/.test(joined)
|
|
407
|
+
|| (names.length === 2 && names[0] === 'vm' && names[1] === 'Script')) {
|
|
408
|
+
dynamic.push({ file: relative, line: lineOf(node), what: joined, item: 'C-16.2' })
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
if (names.length === 2 && names[1] === '_compile' && ['module', 'Module'].includes(names[0])) {
|
|
412
|
+
dynamic.push({ file: relative, line: lineOf(node), what: `${names[0]}._compile`, item: 'C-16.2' })
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
if (names.join('.') === 'process.binding') dynamic.push({ file: relative, line: lineOf(node), what: 'process.binding', item: 'C-16.2' })
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
const dynamicEvidence = dynamic.slice(0, 5).map(item => `${item.file}:${item.line}`)
|
|
420
|
+
const subprocessEvidence = subprocess.slice(0, 5).map(item => `${item.file}:${item.line}`)
|
|
421
|
+
for (const item of dynamic.slice(0, 8)) {
|
|
422
|
+
details.push(detail(item.item, 'FAIL', `dynamic execution (code:dynamic): ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
423
|
+
}
|
|
424
|
+
for (const item of subprocess.slice(0, 8)) {
|
|
425
|
+
details.push(detail('C-16.3', 'WARN', `subprocess capability observed: ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
426
|
+
}
|
|
427
|
+
if (closure.packageImports.length > 0) {
|
|
428
|
+
details.push(detail('C-16.4', 'NA', `not-run: ${closure.packageImports.length} package import(s) are outside the local closure (for example ${closure.packageImports[0].specifier})`, [`${closure.packageImports[0].file}:${closure.packageImports[0].line}`]))
|
|
429
|
+
}
|
|
430
|
+
for (const error of closure.parseErrors) {
|
|
431
|
+
details.push(detail('C-16.4', 'ERROR', `not-run: AST parse failed in ${error.file}:${error.line} (${error.message})`, [`${error.file}:${error.line}`]))
|
|
432
|
+
}
|
|
433
|
+
const coverage = closure.parseErrors.length > 0 || closure.packageImports.length > 0 || closure.unresolved.length > 0 ? 'partial' : 'full'
|
|
434
|
+
let result
|
|
435
|
+
let reason = ''
|
|
436
|
+
if (dynamic.length > 0) {
|
|
437
|
+
result = 'FAIL'
|
|
438
|
+
reason = `${dynamic.length} dynamic-execution site(s) in the server entry closure (code:dynamic)`
|
|
439
|
+
if (closure.parseErrors.length > 0) reason += `; ${closure.parseErrors.length} file(s) could not be parsed`
|
|
440
|
+
} else if (closure.parseErrors.length > 0) {
|
|
441
|
+
result = 'ERROR'
|
|
442
|
+
reason = `AST parse failed in ${closure.parseErrors.length} closure file(s); DSC-16 not-run`
|
|
443
|
+
} else if (subprocess.length > 0) {
|
|
444
|
+
result = 'WARN'
|
|
445
|
+
reason = `${subprocess.length} subprocess site(s) in the server entry closure`
|
|
446
|
+
} else {
|
|
447
|
+
result = 'PASS'
|
|
448
|
+
}
|
|
449
|
+
if (coverage === 'partial' && result === 'PASS') reason = `partial: ${closure.packageImports.length} package/unresolved import(s) were not scanned`
|
|
450
|
+
return check({
|
|
451
|
+
id: 'DSC-16',
|
|
452
|
+
clause: 'C-16',
|
|
453
|
+
layer: 'L0',
|
|
454
|
+
result,
|
|
455
|
+
coverage: result === 'ERROR' ? 'not-run' : coverage,
|
|
456
|
+
reason,
|
|
457
|
+
details,
|
|
458
|
+
evidence: dynamicEvidence.length > 0 ? dynamicEvidence : subprocessEvidence.length > 0 ? subprocessEvidence : [...closure.visited].slice(0, 3),
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// DSC-17: sensitive-file reads (AST argument backtracking)
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
function classifySecretPath(value) {
|
|
467
|
+
const normalized = String(value).replace(/\\/g, '/').toLowerCase()
|
|
468
|
+
if (/(?:^|\/)id_(?:rsa|ed25519|ecdsa|dsa)(?:[^/]*)?$/.test(normalized) || /(?:^|\/)\.ssh(?:\/|$)/.test(normalized)) return 'fail'
|
|
469
|
+
if (/(?:^|\/)\.aws(?:\/|$)|(?:^|\/)\.npmrc$|(?:^|\/)\.netrc$|(?:^|\/)\.pypi(?:rc)?$|(?:^|\/)\.pypirc$/.test(normalized)) return 'fail'
|
|
470
|
+
if (/(?:^|\/)tokens?(?:[-_.][^/]*)?$/.test(normalized)) return 'fail'
|
|
471
|
+
if (/(?:^|\/)\.dsh(?:\/|$)/.test(normalized) && /credential|auth|token|secret|key/.test(normalized)) return 'fail'
|
|
472
|
+
if (/\$dsh_home|\$home/.test(normalized) && /credential|token|secret/.test(normalized)) return 'fail'
|
|
473
|
+
if (/(?:^|\/)\.env(?:$|\.)/.test(normalized) && !/\.(?:example|sample|template|dist)$/.test(normalized)) return 'warn'
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function isEnvEnumeration(node) {
|
|
478
|
+
return node.type === 'CallExpression'
|
|
479
|
+
&& node.callee.type === 'MemberExpression'
|
|
480
|
+
&& identifierName(node.callee.object) === 'Object'
|
|
481
|
+
&& memberPropertyName(node.callee) === 'keys'
|
|
482
|
+
&& node.arguments[0]?.type === 'MemberExpression'
|
|
483
|
+
&& identifierName(node.arguments[0].object) === 'process'
|
|
484
|
+
&& memberPropertyName(node.arguments[0]) === 'env'
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function hasEgressCall(ast) {
|
|
488
|
+
let found = false
|
|
489
|
+
walkAst(ast, (node) => {
|
|
490
|
+
if (found || node.type !== 'CallExpression') return
|
|
491
|
+
const names = chainNames(node.callee)
|
|
492
|
+
if (names[names.length - 1] === 'fetch') found = true
|
|
493
|
+
if (names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(names[names.length - 1])) found = true
|
|
494
|
+
})
|
|
495
|
+
return found
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function checkSensitiveFileReads(manifest, sources) {
|
|
499
|
+
if (!sources || sources.length === 0) {
|
|
500
|
+
return check({
|
|
501
|
+
id: 'DSC-17', clause: 'C-17', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
|
|
502
|
+
reason: 'no source files scanned; DSC-17 not-run',
|
|
503
|
+
details: [detail('C-17.1', 'NA', 'not-run: no source files', [])],
|
|
504
|
+
evidence: [],
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
const details = []
|
|
508
|
+
const failures = []
|
|
509
|
+
const warnings = []
|
|
510
|
+
let parseErrors = 0
|
|
511
|
+
for (const source of serverSources(sources)) {
|
|
512
|
+
const parsed = parseModule(source)
|
|
513
|
+
if (parsed.error) { parseErrors += 1; continue }
|
|
514
|
+
let envEnumeration = false
|
|
515
|
+
walkAst(parsed.ast, (node) => {
|
|
516
|
+
if (node.type !== 'CallExpression') return
|
|
517
|
+
if (isEnvEnumeration(node)) envEnumeration = true
|
|
518
|
+
const api = node.callee.type === 'Identifier' ? node.callee.name : memberPropertyName(node.callee)
|
|
519
|
+
if (!READ_APIS.has(api) || node.arguments.length === 0) return
|
|
520
|
+
const value = stringFromNode(node.arguments[0], parsed.bindings)
|
|
521
|
+
if (value === null || value === '') return
|
|
522
|
+
const classification = classifySecretPath(value)
|
|
523
|
+
if (classification === null) return
|
|
524
|
+
const record = { file: source.relative, line: lineOf(node), api, value }
|
|
525
|
+
if (classification === 'fail') failures.push(record)
|
|
526
|
+
else warnings.push(record)
|
|
527
|
+
})
|
|
528
|
+
if (envEnumeration && hasEgressCall(parsed.ast)) {
|
|
529
|
+
warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', value: 'environment enumeration with egress' })
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
for (const item of failures.slice(0, 8)) {
|
|
533
|
+
details.push(detail('C-17.1', 'FAIL', `${item.api} reads a sensitive path at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
534
|
+
}
|
|
535
|
+
for (const item of warnings.slice(0, 8)) {
|
|
536
|
+
details.push(detail('C-17.2', 'WARN', `${item.api} reads a dotenv/environment surface at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
537
|
+
}
|
|
538
|
+
if (parseErrors > 0) {
|
|
539
|
+
details.push(detail('C-17.3', 'ERROR', `not-run: AST parse failed in ${parseErrors} source file(s)`, []))
|
|
540
|
+
}
|
|
541
|
+
if (failures.length === 0 && warnings.length === 0 && parseErrors === 0) {
|
|
542
|
+
details.push(detail('C-17.1', 'PASS', 'no sensitive-file read argument found in the scanned sources'))
|
|
543
|
+
}
|
|
544
|
+
let result
|
|
545
|
+
if (failures.length > 0) result = 'FAIL'
|
|
546
|
+
else if (parseErrors > 0) result = 'ERROR'
|
|
547
|
+
else if (warnings.length > 0) result = 'WARN'
|
|
548
|
+
else result = 'PASS'
|
|
549
|
+
return check({
|
|
550
|
+
id: 'DSC-17',
|
|
551
|
+
clause: 'C-17',
|
|
552
|
+
layer: 'L0',
|
|
553
|
+
result,
|
|
554
|
+
coverage: parseErrors > 0 ? 'partial' : 'full',
|
|
555
|
+
reason: failures.length > 0
|
|
556
|
+
? `${failures.length} sensitive-file read(s) in API arguments`
|
|
557
|
+
: parseErrors > 0
|
|
558
|
+
? `AST parse failed in ${parseErrors} file(s); DSC-17 partial`
|
|
559
|
+
: warnings.length > 0 ? `${warnings.length} dotenv/environment clue(s)` : '',
|
|
560
|
+
details,
|
|
561
|
+
evidence: failures.slice(0, 5).map(item => `${item.file}:${item.line}`),
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// DSC-18: egress endpoints
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
function isPrivateIp(host) {
|
|
570
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false
|
|
571
|
+
const parts = host.split('.').map(Number)
|
|
572
|
+
if (parts.some(part => part > 255)) return false
|
|
573
|
+
if (parts[0] === 10 || parts[0] === 127) return true
|
|
574
|
+
if (parts[0] === 192 && parts[1] === 168) return true
|
|
575
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
|
|
576
|
+
if (parts[0] === 169 && parts[1] === 254) return true
|
|
577
|
+
return false
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function egressEndpointFrom(node, bindings) {
|
|
581
|
+
if (!node) return null
|
|
582
|
+
if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') return node.arguments[0]
|
|
583
|
+
return node
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function parseEndpoint(raw) {
|
|
587
|
+
try {
|
|
588
|
+
const url = new URL(raw)
|
|
589
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
590
|
+
return {
|
|
591
|
+
url,
|
|
592
|
+
normalized: `${url.protocol}//${url.host}${pathname}`,
|
|
593
|
+
host: url.hostname,
|
|
594
|
+
}
|
|
595
|
+
} catch {
|
|
596
|
+
return null
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function checkEgressEndpoints(manifest, sources) {
|
|
601
|
+
if (!sources || sources.length === 0) {
|
|
602
|
+
return check({
|
|
603
|
+
id: 'DSC-18', clause: 'C-18', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
|
|
604
|
+
reason: 'no source files scanned; DSC-18 not-run',
|
|
605
|
+
details: [detail('C-18.3', 'NA', 'not-run: no source files', [])],
|
|
606
|
+
evidence: [],
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
const endpoints = []
|
|
610
|
+
const dynamic = []
|
|
611
|
+
let parseErrors = 0
|
|
612
|
+
for (const source of serverSources(sources)) {
|
|
613
|
+
const parsed = parseModule(source)
|
|
614
|
+
if (parsed.error) { parseErrors += 1; continue }
|
|
615
|
+
walkAst(parsed.ast, (node) => {
|
|
616
|
+
let target = null
|
|
617
|
+
if (node.type === 'CallExpression') {
|
|
618
|
+
const names = chainNames(node.callee)
|
|
619
|
+
const last = names[names.length - 1]
|
|
620
|
+
if (last === 'fetch' || (names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(last)) || names[0] === 'axios') {
|
|
621
|
+
target = egressEndpointFrom(node.arguments[0], parsed.bindings)
|
|
622
|
+
}
|
|
623
|
+
} else if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') {
|
|
624
|
+
target = egressEndpointFrom(node, parsed.bindings)
|
|
625
|
+
}
|
|
626
|
+
if (!target) return
|
|
627
|
+
const raw = stringFromNode(target, parsed.bindings)
|
|
628
|
+
if (raw === null) {
|
|
629
|
+
dynamic.push({ file: source.relative, line: lineOf(node) })
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
const endpoint = parseEndpoint(raw)
|
|
633
|
+
if (!endpoint) return
|
|
634
|
+
endpoints.push({ file: source.relative, line: lineOf(node), ...endpoint, raw })
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
const disclosure = manifest?.dsh?.disclosure
|
|
638
|
+
const declared = Array.isArray(disclosure?.endpoints) ? disclosure.endpoints : null
|
|
639
|
+
const declaredNormalized = declared === null ? null : new Set(declared.map(value => {
|
|
640
|
+
const parsed = parseEndpoint(String(value))
|
|
641
|
+
return parsed ? parsed.normalized : String(value)
|
|
642
|
+
}))
|
|
643
|
+
const details = []
|
|
644
|
+
const warnings = []
|
|
645
|
+
for (const endpoint of endpoints) {
|
|
646
|
+
const clues = []
|
|
647
|
+
if (endpoint.url.protocol !== 'https:') clues.push(`non-https scheme ${endpoint.url.protocol}`)
|
|
648
|
+
if (endpoint.url.username || endpoint.url.password) clues.push('userinfo in the URL')
|
|
649
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(endpoint.host) && !isPrivateIp(endpoint.host)) clues.push('public bare IP host')
|
|
650
|
+
if (SUSPICIOUS_HOST.test(endpoint.host) || /\.onion$/i.test(endpoint.host)) clues.push('webhook/paste/ngrok/onion host')
|
|
651
|
+
if (/^xn--/i.test(endpoint.host) || endpoint.host.includes('.xn--')) clues.push('punycode host')
|
|
652
|
+
if (declaredNormalized !== null && !declaredNormalized.has(endpoint.normalized)) clues.push('endpoint is not listed in dsh.disclosure.endpoints')
|
|
653
|
+
if (clues.length > 0) warnings.push({ ...endpoint, clues, item: clues.some(clue => clue.startsWith('endpoint is not listed')) ? 'C-18.2' : 'C-18.1' })
|
|
654
|
+
}
|
|
655
|
+
for (const item of warnings.slice(0, 8)) {
|
|
656
|
+
details.push(detail(item.item, 'WARN', `${item.clues.join('; ')} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
657
|
+
}
|
|
658
|
+
for (const item of dynamic.slice(0, 3)) {
|
|
659
|
+
details.push(detail('C-18.3', 'NA', `not-run: dynamic endpoint argument at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
660
|
+
}
|
|
661
|
+
if (parseErrors > 0) {
|
|
662
|
+
details.push(detail('C-18.3', 'NA', `not-run: AST parse failed in ${parseErrors} source file(s); endpoint coverage is partial`, []))
|
|
663
|
+
}
|
|
664
|
+
for (const endpoint of endpoints.slice(0, 10)) {
|
|
665
|
+
if (warnings.some(item => item.file === endpoint.file && item.line === endpoint.line)) continue
|
|
666
|
+
details.push(detail('C-18.3', 'PASS', `registered egress endpoint ${endpoint.normalized} at ${endpoint.file}:${endpoint.line}`, [`${endpoint.file}:${endpoint.line}`]))
|
|
667
|
+
}
|
|
668
|
+
if (endpoints.length === 0 && dynamic.length === 0) {
|
|
669
|
+
details.push(detail('C-18.3', 'PASS', 'no fetch/http request call site found'))
|
|
670
|
+
}
|
|
671
|
+
if (warnings.length === 0 && endpoints.length > 0) {
|
|
672
|
+
details.push(detail('C-18.3', 'PASS', `${endpoints.length} egress endpoint(s) registered; no suspicious host or scheme`))
|
|
673
|
+
}
|
|
674
|
+
const result = warnings.length > 0 ? 'WARN' : 'PASS'
|
|
675
|
+
return check({
|
|
676
|
+
id: 'DSC-18',
|
|
677
|
+
clause: 'C-18',
|
|
678
|
+
layer: 'L0',
|
|
679
|
+
result,
|
|
680
|
+
coverage: dynamic.length > 0 || parseErrors > 0 ? 'partial' : 'full',
|
|
681
|
+
advisory: true,
|
|
682
|
+
reason: warnings.length > 0 ? `${warnings.length} egress endpoint clue(s) (advisory)` : '',
|
|
683
|
+
details,
|
|
684
|
+
evidence: warnings.slice(0, 5).map(item => `${item.file}:${item.line}`),
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
// DSC-19: opaque text / binary artifacts
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
function shannonEntropy(text) {
|
|
693
|
+
const counts = new Map()
|
|
694
|
+
for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1)
|
|
695
|
+
let entropy = 0
|
|
696
|
+
const length = text.length
|
|
697
|
+
for (const count of counts.values()) {
|
|
698
|
+
const probability = count / length
|
|
699
|
+
entropy -= probability * Math.log2(probability)
|
|
700
|
+
}
|
|
701
|
+
return entropy
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function checkOpaqueArtifacts(dir, sources) {
|
|
705
|
+
if (!dir) {
|
|
706
|
+
return check({
|
|
707
|
+
id: 'DSC-19', clause: 'C-19', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
|
|
708
|
+
reason: 'no package directory; DSC-19 not-run',
|
|
709
|
+
details: [detail('C-19.1', 'NA', 'not-run: no package directory', [])],
|
|
710
|
+
evidence: [],
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
const inventory = inventoryFiles(dir)
|
|
714
|
+
const details = []
|
|
715
|
+
const binaries = []
|
|
716
|
+
const opaque = []
|
|
717
|
+
for (const file of inventory.files) {
|
|
718
|
+
const extension = path.extname(file.relative).toLowerCase()
|
|
719
|
+
if (BINARY_EXTENSIONS.has(extension)) {
|
|
720
|
+
let buffer
|
|
721
|
+
try { buffer = fs.readFileSync(file.file) } catch { continue }
|
|
722
|
+
const sha256 = createHash('sha256').update(buffer).digest('hex')
|
|
723
|
+
binaries.push({ relative: file.relative, size: file.size, sha256 })
|
|
724
|
+
continue
|
|
725
|
+
}
|
|
726
|
+
if (!TEXT_EXTENSIONS.test(extension) || file.size > 512_000) continue
|
|
727
|
+
let text
|
|
728
|
+
try { text = fs.readFileSync(file.file, 'utf8') } catch { continue }
|
|
729
|
+
if (/(?:\/\/[#@]\s*|\/\*[#@]?\s*)sourceMappingURL=/.test(text)) continue
|
|
730
|
+
const longestLine = text.split(/\r?\n/).reduce((max, line) => Math.max(max, line.length), 0)
|
|
731
|
+
if (longestLine > 20_000) {
|
|
732
|
+
opaque.push({ relative: file.relative, clue: `single line of ${longestLine} chars without a sourcemap` })
|
|
733
|
+
continue
|
|
734
|
+
}
|
|
735
|
+
if (/(?:^|[^A-Za-z0-9+/=])[A-Za-z0-9+/]{4000,}={0,2}(?:[^A-Za-z0-9+/=]|$)/.test(text)) {
|
|
736
|
+
opaque.push({ relative: file.relative, clue: 'base64 block of 4000+ chars' })
|
|
737
|
+
continue
|
|
738
|
+
}
|
|
739
|
+
if (text.length > 4096 && shannonEntropy(text) > 7.5) {
|
|
740
|
+
opaque.push({ relative: file.relative, clue: `high entropy ${shannonEntropy(text).toFixed(2)} over ${text.length} chars` })
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
for (const item of opaque.slice(0, 8)) {
|
|
744
|
+
details.push(detail('C-19.1', 'WARN', `opaque text clue: ${item.clue} in ${item.relative}`, [item.relative]))
|
|
745
|
+
}
|
|
746
|
+
for (const item of binaries.slice(0, 8)) {
|
|
747
|
+
details.push(detail('C-19.2', 'WARN', `binary artifact ${item.relative} (${item.size} bytes, sha256:${item.sha256}) is recorded, not judged`, [item.relative]))
|
|
748
|
+
}
|
|
749
|
+
if (opaque.length === 0 && binaries.length === 0) {
|
|
750
|
+
details.push(detail('C-19.1', 'PASS', 'no opaque text or binary artifact above the advisory thresholds'))
|
|
751
|
+
}
|
|
752
|
+
if (inventory.stats.truncated) {
|
|
753
|
+
details.push(detail('C-19.1', 'NA', 'not-run: file inventory was truncated; opaque artifacts beyond the budget were not scanned', []))
|
|
754
|
+
}
|
|
755
|
+
const result = opaque.length > 0 || binaries.length > 0 ? 'WARN' : 'PASS'
|
|
756
|
+
return check({
|
|
757
|
+
id: 'DSC-19',
|
|
758
|
+
clause: 'C-19',
|
|
759
|
+
layer: 'L0',
|
|
760
|
+
result,
|
|
761
|
+
coverage: inventory.stats.truncated ? 'partial' : 'full',
|
|
762
|
+
advisory: true,
|
|
763
|
+
reason: result === 'WARN' ? `${opaque.length} opaque text clue(s), ${binaries.length} binary artifact(s) (advisory)` : '',
|
|
764
|
+
details,
|
|
765
|
+
evidence: [...opaque, ...binaries].slice(0, 5).map(item => item.relative),
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
// DSC-20: prompt-injection clues
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
function propertyKeyOf(ancestors) {
|
|
774
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
775
|
+
const node = ancestors[index]
|
|
776
|
+
if (node.type === 'Property') return keyName(node)
|
|
777
|
+
if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') continue
|
|
778
|
+
return null
|
|
779
|
+
}
|
|
780
|
+
return null
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function isRuleContext(ancestors, node) {
|
|
784
|
+
const key = propertyKeyOf(ancestors)
|
|
785
|
+
if (key && RULE_KEYS.has(key)) return true
|
|
786
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
787
|
+
const ancestor = ancestors[index]
|
|
788
|
+
if (ancestor.type === 'NewExpression' && chainNames(ancestor.callee).join('.') === 'RegExp') return true
|
|
789
|
+
if (ancestor.type === 'CallExpression' && ancestor.callee.type === 'MemberExpression') {
|
|
790
|
+
const method = memberPropertyName(ancestor.callee)
|
|
791
|
+
if (['test', 'match', 'matchAll', 'exec', 'includes', 'compile', 'replace', 'split'].includes(method)) return true
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return false
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function collectParameterNames(ast) {
|
|
798
|
+
const names = new Set()
|
|
799
|
+
walkAst(ast, (node) => {
|
|
800
|
+
if (!['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type)) return
|
|
801
|
+
for (const parameter of node.params ?? []) {
|
|
802
|
+
if (parameter.type === 'Identifier') names.add(parameter.name)
|
|
803
|
+
if (parameter.type === 'AssignmentPattern' && parameter.left?.type === 'Identifier') names.add(parameter.left.name)
|
|
804
|
+
}
|
|
805
|
+
})
|
|
806
|
+
return names
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function isExternalExpression(node, bindings, parameters, depth = 0, seen = new Set()) {
|
|
810
|
+
if (!node || depth > 4 || seen.has(node)) return false
|
|
811
|
+
if (node.type === 'Identifier') {
|
|
812
|
+
if (parameters.has(node.name)) return true
|
|
813
|
+
const init = bindings.get(node.name)
|
|
814
|
+
if (!init) return false
|
|
815
|
+
return isExternalExpression(init, bindings, parameters, depth + 1, new Set(seen).add(node))
|
|
816
|
+
}
|
|
817
|
+
if (node.type === 'AwaitExpression') return isExternalExpression(node.argument, bindings, parameters, depth + 1, new Set(seen).add(node))
|
|
818
|
+
if (node.type === 'TemplateLiteral') {
|
|
819
|
+
return node.expressions.some(expression => isExternalExpression(expression, bindings, parameters, depth + 1, new Set(seen).add(node)))
|
|
820
|
+
}
|
|
821
|
+
if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') {
|
|
822
|
+
return isExternalExpression(node.left, bindings, parameters, depth + 1, new Set(seen).add(node))
|
|
823
|
+
|| isExternalExpression(node.right, bindings, parameters, depth + 1, new Set(seen).add(node))
|
|
824
|
+
}
|
|
825
|
+
if (node.type === 'MemberExpression') {
|
|
826
|
+
return ['tools', 'toolResult', 'messages', 'results'].includes(memberPropertyName(node))
|
|
827
|
+
}
|
|
828
|
+
if (node.type === 'CallExpression') {
|
|
829
|
+
const names = chainNames(node.callee)
|
|
830
|
+
const joined = names.join('.').toLowerCase()
|
|
831
|
+
return /fetch|request|tool|invoke|call|read|query|search|message|completion/.test(joined)
|
|
832
|
+
}
|
|
833
|
+
return false
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export function checkPromptInjectionClues(manifest, sources) {
|
|
837
|
+
if (!sources || sources.length === 0) {
|
|
838
|
+
return check({
|
|
839
|
+
id: 'DSC-20', clause: 'C-20', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
|
|
840
|
+
reason: 'no source files scanned; DSC-20 not-run',
|
|
841
|
+
details: [detail('C-20.1', 'NA', 'not-run: no source files', [])],
|
|
842
|
+
evidence: [],
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
const details = []
|
|
846
|
+
const clues = []
|
|
847
|
+
for (const source of serverSources(sources)) {
|
|
848
|
+
if (RULE_FILE.test(source.relative)) continue
|
|
849
|
+
const parsed = parseModule(source)
|
|
850
|
+
if (parsed.error) continue
|
|
851
|
+
const parameters = collectParameterNames(parsed.ast)
|
|
852
|
+
walkAst(parsed.ast, (node, ancestors) => {
|
|
853
|
+
if (node.type !== 'CallExpression') return
|
|
854
|
+
const names = chainNames(node.callee)
|
|
855
|
+
if (!names.includes('systemPrompt')) return
|
|
856
|
+
const method = names[names.length - 1]
|
|
857
|
+
if (!['section', 'context', 'variable'].includes(method)) return
|
|
858
|
+
const argument = node.arguments[0]
|
|
859
|
+
if (!argument) return
|
|
860
|
+
if (isRuleContext(ancestors, node)) return
|
|
861
|
+
const property = propertyKeyOf(ancestors)
|
|
862
|
+
if (property && DOC_KEYS.has(property)) return
|
|
863
|
+
const literal = argument.type === 'TemplateLiteral'
|
|
864
|
+
? stringFromNode(argument, parsed.bindings)
|
|
865
|
+
: literalStringValue(argument)
|
|
866
|
+
if (literal !== null) {
|
|
867
|
+
if (INSTRUCTION_PATTERN.test(literal)) {
|
|
868
|
+
clues.push({ file: source.relative, line: lineOf(node), what: 'instruction-like systemPrompt literal' })
|
|
869
|
+
}
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
if (isExternalExpression(argument, parsed.bindings, parameters)) {
|
|
873
|
+
clues.push({ file: source.relative, line: lineOf(node), what: 'systemPrompt argument derives from external input' })
|
|
874
|
+
}
|
|
875
|
+
})
|
|
876
|
+
}
|
|
877
|
+
for (const clue of clues.slice(0, 8)) {
|
|
878
|
+
details.push(detail('C-20.1', 'WARN', `${clue.what} at ${clue.file}:${clue.line}; this is a clue, not a judgement`, [`${clue.file}:${clue.line}`]))
|
|
879
|
+
}
|
|
880
|
+
if (clues.length === 0) details.push(detail('C-20.1', 'PASS', 'no prompt-surface clue found (instruction-like literal or external systemPrompt argument)'))
|
|
881
|
+
return check({
|
|
882
|
+
id: 'DSC-20',
|
|
883
|
+
clause: 'C-20',
|
|
884
|
+
layer: 'L0',
|
|
885
|
+
result: clues.length > 0 ? 'WARN' : 'PASS',
|
|
886
|
+
coverage: 'full',
|
|
887
|
+
advisory: true,
|
|
888
|
+
reason: clues.length > 0 ? `${clues.length} prompt-injection clue(s); semantic intent is not judged` : '',
|
|
889
|
+
details,
|
|
890
|
+
evidence: clues.slice(0, 5).map(clue => `${clue.file}:${clue.line}`),
|
|
891
|
+
})
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
// DSC-21: supply-chain name similarity
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
const KNOWN_SEEDS = [
|
|
899
|
+
'@deepseek-ai/dsh', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-app-boot',
|
|
900
|
+
'@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/dsh-credentials',
|
|
901
|
+
'@deepseek-ai/dsh-credentials-local', '@deepseek-ai/dsh-session',
|
|
902
|
+
'@deepseek-ai/dsh-system-prompt', '@deepseek-ai/dsh-fs', '@deepseek-ai/dsh-fs-sandbox',
|
|
903
|
+
'@deepseek-ai/dsh-sandbox', '@deepseek-ai/dsh-interaction', '@deepseek-ai/dsh-llm',
|
|
904
|
+
'@deepseek-ai/dsh-web', '@deepseek-ai/dsh-client', 'cordis',
|
|
905
|
+
'@cordisjs/core', 'schemastery', 'cosmokit',
|
|
906
|
+
]
|
|
907
|
+
const KNOWN_SET = new Set(KNOWN_SEEDS)
|
|
908
|
+
const SEED_BASENAMES = [...new Set(KNOWN_SEEDS.map(name => name.split('/').pop()))]
|
|
909
|
+
|
|
910
|
+
function editDistanceWithin(left, right, limit) {
|
|
911
|
+
if (Math.abs(left.length - right.length) > limit) return false
|
|
912
|
+
const previous = new Array(right.length + 1).fill(0).map((_, index) => index)
|
|
913
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
914
|
+
let diagonal = previous[0]
|
|
915
|
+
previous[0] = i
|
|
916
|
+
let rowMin = previous[0]
|
|
917
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
918
|
+
const above = previous[j]
|
|
919
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1
|
|
920
|
+
previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + cost)
|
|
921
|
+
diagonal = above
|
|
922
|
+
rowMin = Math.min(rowMin, previous[j])
|
|
923
|
+
}
|
|
924
|
+
if (rowMin > limit) return false
|
|
925
|
+
}
|
|
926
|
+
return previous[right.length] <= limit
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function normalizeHomoglyph(value) {
|
|
930
|
+
return String(value).toLowerCase().replace(/0/g, 'o').replace(/1/g, 'l').replace(/rn/g, 'm')
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function scopeOf(name) {
|
|
934
|
+
return name.startsWith('@') ? name.split('/')[0] : null
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export function checkSupplyChain(manifest) {
|
|
938
|
+
const dependencies = {
|
|
939
|
+
...(manifest?.dependencies && typeof manifest.dependencies === 'object' ? manifest.dependencies : {}),
|
|
940
|
+
...(manifest?.optionalDependencies && typeof manifest.optionalDependencies === 'object' ? manifest.optionalDependencies : {}),
|
|
941
|
+
...(manifest?.peerDependencies && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {}),
|
|
942
|
+
}
|
|
943
|
+
const names = Object.keys(dependencies)
|
|
944
|
+
const details = []
|
|
945
|
+
const findings = []
|
|
946
|
+
let knownCount = 0
|
|
947
|
+
for (const name of names) {
|
|
948
|
+
if (KNOWN_SET.has(name)) { knownCount += 1; continue }
|
|
949
|
+
const scope = scopeOf(name)
|
|
950
|
+
if (scope && scope !== '@deepseek-ai' && (editDistanceWithin(scope, '@deepseek-ai', 1) || normalizeHomoglyph(scope) === normalizeHomoglyph('@deepseek-ai'))) {
|
|
951
|
+
findings.push({ name, item: 'C-21.2', why: `dependency scope ${scope} is a near-miss of the official @deepseek-ai scope` })
|
|
952
|
+
continue
|
|
953
|
+
}
|
|
954
|
+
const basename = name.split('/').pop()
|
|
955
|
+
if (!/dsh/i.test(basename) && !/dsh/i.test(scope ?? '')) continue
|
|
956
|
+
const similar = SEED_BASENAMES.find(seed => seed !== basename && editDistanceWithin(basename, seed, 1))
|
|
957
|
+
if (similar) findings.push({ name, item: 'C-21.1', why: `dependency ${name} is one edit away from known package ${similar}` })
|
|
958
|
+
}
|
|
959
|
+
for (const finding of findings.slice(0, 8)) {
|
|
960
|
+
details.push(detail(finding.item, 'WARN', finding.why, [`package.json#dependencies.${finding.name}`]))
|
|
961
|
+
}
|
|
962
|
+
if (names.length === 0) {
|
|
963
|
+
details.push(detail('C-21.1', 'PASS', 'no runtime dependencies declared'))
|
|
964
|
+
return check({
|
|
965
|
+
id: 'DSC-21', clause: 'C-21', layer: 'L0', result: 'PASS', coverage: 'full', advisory: false,
|
|
966
|
+
reason: '', details, evidence: ['package.json#dependencies'],
|
|
967
|
+
})
|
|
968
|
+
}
|
|
969
|
+
details.push(detail('C-21.3', 'NA', 'not-run: dependency install scripts and transitive registry metadata need registry data; a missing lockfile is not treated as a defect', []))
|
|
970
|
+
if (findings.length === 0) {
|
|
971
|
+
details.push(detail('C-21.1', 'PASS', `${names.length} dependency name(s) checked; ${knownCount} exact known name(s)`))
|
|
972
|
+
}
|
|
973
|
+
return check({
|
|
974
|
+
id: 'DSC-21',
|
|
975
|
+
clause: 'C-21',
|
|
976
|
+
layer: 'L0',
|
|
977
|
+
result: findings.length > 0 ? 'WARN' : 'PASS',
|
|
978
|
+
coverage: 'partial',
|
|
979
|
+
advisory: false,
|
|
980
|
+
reason: findings.length > 0 ? `${findings.length} supply-chain name clue(s)` : '',
|
|
981
|
+
details,
|
|
982
|
+
evidence: findings.slice(0, 5).map(finding => `package.json#dependencies.${finding.name}`),
|
|
983
|
+
})
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ---------------------------------------------------------------------------
|
|
987
|
+
// DSC-22: provenance / integrity (advisory only)
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
|
|
990
|
+
export function checkProvenance(manifest) {
|
|
991
|
+
const integrity = manifest?.dist?.integrity
|
|
992
|
+
const attestation = manifest?.dsh?.attestation
|
|
993
|
+
const signature = manifest?.dsh?.signature
|
|
994
|
+
const details = []
|
|
995
|
+
if (typeof integrity === 'string' && integrity.trim() !== '') {
|
|
996
|
+
details.push(detail('C-22.1', 'PASS', 'dist.integrity is present; this is integrity-only and does not prove provenance', ['package.json#dist.integrity']))
|
|
997
|
+
return check({
|
|
998
|
+
id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'PASS', coverage: 'full', advisory: true,
|
|
999
|
+
reason: '', details, evidence: ['package.json#dist.integrity'],
|
|
1000
|
+
})
|
|
1001
|
+
}
|
|
1002
|
+
if ((typeof attestation === 'object' && attestation !== null) || (typeof signature === 'object' && signature !== null) || typeof attestation === 'string' || typeof signature === 'string') {
|
|
1003
|
+
details.push(detail('C-22.1', 'PASS', 'attestation/signature field is present (self-declared; not verified by this checker)', ['package.json#dsh.attestation', 'package.json#dsh.signature']))
|
|
1004
|
+
return check({
|
|
1005
|
+
id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'PASS', coverage: 'full', advisory: true,
|
|
1006
|
+
reason: '', details, evidence: ['package.json#dsh.attestation'],
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
details.push(detail('C-22.1', 'NA', 'not-run: no registry metadata or attestation supplied; provenance is not judged', []))
|
|
1010
|
+
return check({
|
|
1011
|
+
id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
|
|
1012
|
+
reason: 'no registry metadata/attestation input; not-run',
|
|
1013
|
+
details, evidence: [],
|
|
1014
|
+
})
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
// aggregate
|
|
1019
|
+
// ---------------------------------------------------------------------------
|
|
1020
|
+
|
|
1021
|
+
function unavailable(reason) {
|
|
1022
|
+
return SECURITY_IDS.map(id => check({
|
|
1023
|
+
id,
|
|
1024
|
+
clause: SECURITY_CLAUSE[id],
|
|
1025
|
+
layer: 'L0',
|
|
1026
|
+
result: 'ERROR',
|
|
1027
|
+
coverage: 'not-run',
|
|
1028
|
+
advisory: SECURITY_ADVISORY.has(id),
|
|
1029
|
+
reason,
|
|
1030
|
+
details: [detail(SECURITY_CLAUSE[id], 'ERROR', `not-run: ${reason}`, [])],
|
|
1031
|
+
evidence: [],
|
|
1032
|
+
}))
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Run all eight security checks. Never throws: a failure becomes not-run rows. */
|
|
1036
|
+
export function runSecurityChecks({ dir, manifest, sources = [], entry = null, kind = null } = {}) {
|
|
1037
|
+
if (!manifest) return unavailable('manifest unavailable')
|
|
1038
|
+
const checks = []
|
|
1039
|
+
const run = (id, factory) => {
|
|
1040
|
+
try {
|
|
1041
|
+
checks.push(factory())
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
checks.push(check({
|
|
1044
|
+
id,
|
|
1045
|
+
clause: SECURITY_CLAUSE[id],
|
|
1046
|
+
layer: 'L0',
|
|
1047
|
+
result: 'ERROR',
|
|
1048
|
+
coverage: 'not-run',
|
|
1049
|
+
advisory: SECURITY_ADVISORY.has(id),
|
|
1050
|
+
reason: `checker error: ${error?.message ?? error}`,
|
|
1051
|
+
details: [detail(SECURITY_CLAUSE[id], 'ERROR', `not-run: ${error?.message ?? error}`, [])],
|
|
1052
|
+
evidence: [],
|
|
1053
|
+
}))
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
run('DSC-15', () => checkLifecycleScripts(manifest, dir))
|
|
1057
|
+
run('DSC-16', () => checkDynamicExecution(manifest, sources, entry, kind))
|
|
1058
|
+
run('DSC-17', () => checkSensitiveFileReads(manifest, sources))
|
|
1059
|
+
run('DSC-18', () => checkEgressEndpoints(manifest, sources))
|
|
1060
|
+
run('DSC-19', () => checkOpaqueArtifacts(dir, sources))
|
|
1061
|
+
run('DSC-20', () => checkPromptInjectionClues(manifest, sources))
|
|
1062
|
+
run('DSC-21', () => checkSupplyChain(manifest))
|
|
1063
|
+
run('DSC-22', () => checkProvenance(manifest))
|
|
1064
|
+
return checks
|
|
1065
|
+
}
|