dsh-standards 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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-install-cred/index.mjs +7 -0
- package/40-checker/fixtures/security/sec15-install-cred/install.js +8 -0
- package/40-checker/fixtures/security/sec15-install-cred/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-curl/index.mjs +7 -0
- package/40-checker/fixtures/security/sec15-install-curl/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-curl/scripts/install.sh +4 -0
- package/40-checker/fixtures/security/sec15-install-danger/index.mjs +7 -0
- package/40-checker/fixtures/security/sec15-install-danger/install.js +5 -0
- package/40-checker/fixtures/security/sec15-install-danger/package.json +22 -0
- package/40-checker/fixtures/security/sec15-install-safe/index.mjs +7 -0
- package/40-checker/fixtures/security/sec15-install-safe/install.js +5 -0
- package/40-checker/fixtures/security/sec15-install-safe/package.json +22 -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-cred-exfil/index.mjs +22 -0
- package/40-checker/fixtures/security/sec17-cred-exfil/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 +1353 -0
- package/40-checker/src/util.mjs +53 -0
- package/CHANGELOG.md +25 -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,1353 @@
|
|
|
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
|
+
const SCRIPT_FILE_EXTENSION = /\.(?:[cm]?[jt]s|sh|bash|zsh|ps1)$/i
|
|
262
|
+
const SCRIPT_INSPECT_MAX_BYTES = 512 * 1024
|
|
263
|
+
const SCRIPT_DANGER_RULES = [
|
|
264
|
+
{ pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|fish)\b/, label: 'remote script piped to a shell' },
|
|
265
|
+
{ pattern: /\bbase64\s+(?:-d|--decode)\b|\bbase64\s+-D\b/, label: 'base64 decode' },
|
|
266
|
+
{ pattern: /\beval\b\s*\)?\s*\(|\bnew\s+Function\s*\(|\bvm\.(?:runIn\w+|compileFunction)\s*\(/, label: 'eval/Function of a string' },
|
|
267
|
+
{ pattern: /\bnode\s+(?:-e|--eval)\b|\b(?:sh|bash|zsh|dash|ksh)\s+-[a-z]*c\b/, label: 'inline code passed to an interpreter' },
|
|
268
|
+
{ pattern: /\bpowershell(?:\.exe)?\b[^\n]*(?:-enc|-encodedcommand)\b/i, label: 'powershell encoded command' },
|
|
269
|
+
{ pattern: /(?:readFile(?:Sync)?|createReadStream|open(?:Sync)?)\s*\([^\n]{0,200}(?:\.ssh|\.dsh|credential|id_rsa|id_ed25519|\.aws|\.npmrc|\.netrc|tokens?)/i, label: 'reads a credential-like path' },
|
|
270
|
+
{ pattern: /\b(?:cat|cp|scp|tar|zip)\b[^\n]{0,160}(?:\.ssh\/|\.dsh\/|credentials|id_rsa|\.aws\/|\.npmrc|\.netrc)/i, label: 'collects credential-like files' },
|
|
271
|
+
{ pattern: /(?:os\.homedir\(\)|process\.env\.(?:HOME|USERPROFILE|DSH_HOME))[^\n]{0,160}(?:\.dsh|\.ssh|credential|tokens?|id_rsa)/i, label: 'combines the home directory with a secret path' },
|
|
272
|
+
]
|
|
273
|
+
const SCRIPT_NETWORK = /\b(?:fetch|axios\.(?:get|post)|https?\.(?:get|request))\s*\(|\bcurl\b|\bwget\b/
|
|
274
|
+
const SCRIPT_EXECUTE = /\beval\b|\bnew\s+Function\b|\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(|\bvm\.(?:runIn\w+|compileFunction)/
|
|
275
|
+
const SCRIPT_CHILD_DYNAMIC = [
|
|
276
|
+
/\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(\s*`[^`]*\$\{/,
|
|
277
|
+
/\b(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(\s*[A-Za-z_$][\w$]*\s*[,)]/,
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
function scriptDangerLabels(text) {
|
|
281
|
+
const labels = []
|
|
282
|
+
const add = (label) => { if (!labels.includes(label)) labels.push(label) }
|
|
283
|
+
for (const rule of SCRIPT_DANGER_RULES) if (rule.pattern.test(text)) add(rule.label)
|
|
284
|
+
if (SCRIPT_NETWORK.test(text) && SCRIPT_EXECUTE.test(text)) add('downloads remote content and executes it')
|
|
285
|
+
if (/\bchild_process\b/.test(text) && SCRIPT_CHILD_DYNAMIC.some(pattern => pattern.test(text))) {
|
|
286
|
+
add('child_process with a dynamic command')
|
|
287
|
+
}
|
|
288
|
+
return labels
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Local script paths referenced by one lifecycle command (node/sh/tsx/direct). */
|
|
292
|
+
function commandScriptReferences(command) {
|
|
293
|
+
const references = []
|
|
294
|
+
const add = (value) => {
|
|
295
|
+
if (!value || value.startsWith('-')) return
|
|
296
|
+
if (value.includes('://')) return
|
|
297
|
+
if (!SCRIPT_FILE_EXTENSION.test(value)) return
|
|
298
|
+
if (!references.includes(value)) references.push(value)
|
|
299
|
+
}
|
|
300
|
+
for (const match of command.matchAll(/\bnode\s+(?!-e\b|--eval\b)(?:(?:--?[\w-]+(?:=[^\s]+)?)\s+)*([^\s;&|()]+)/g)) add(match[1])
|
|
301
|
+
for (const match of command.matchAll(/\b(?:sh|bash|zsh|dash|ksh|fish)\s+(?!-[a-z]*c\b)([^\s;&|()]+)/g)) add(match[1])
|
|
302
|
+
for (const match of command.matchAll(/(?:^|[;&|]\s*|\b(?:then|do|else)\s+)((?:\.{1,2}\/)?[\w@./-]+\.(?:[cm]?[jt]s|sh|ps1))/g)) add(match[1])
|
|
303
|
+
return references
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Follow `npm run x` one level so `postinstall: npm run setup` is inspectable. */
|
|
307
|
+
function collectScriptFacts(command, dir, scripts, depth = 0, seen = new Set(), acc = null) {
|
|
308
|
+
const facts = acc ?? { references: [], missing: [] }
|
|
309
|
+
for (const reference of commandScriptReferences(command)) {
|
|
310
|
+
if (!facts.references.includes(reference)) facts.references.push(reference)
|
|
311
|
+
}
|
|
312
|
+
for (const match of command.matchAll(/\b(?:npm|pnpm|yarn)\s+run\s+([\w:.@-]+)/g)) {
|
|
313
|
+
const name = match[1]
|
|
314
|
+
if (!Object.hasOwn(scripts, name)) {
|
|
315
|
+
if (!facts.missing.includes(`npm run ${name}`)) facts.missing.push(`npm run ${name}`)
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
if (depth >= 2 || seen.has(name)) continue
|
|
319
|
+
seen.add(name)
|
|
320
|
+
collectScriptFacts(String(scripts[name] ?? ''), dir, scripts, depth + 1, seen, facts)
|
|
321
|
+
}
|
|
322
|
+
return facts
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Read a referenced local script and look for install-time danger. The file is
|
|
327
|
+
* never executed; only text is inspected and the walk is bounded to the
|
|
328
|
+
* package directory so a lifecycle command cannot make the checker read an
|
|
329
|
+
* arbitrary host file.
|
|
330
|
+
*/
|
|
331
|
+
function inspectInstallScript(reference, dir) {
|
|
332
|
+
const root = path.resolve(dir)
|
|
333
|
+
const absolute = path.resolve(root, reference)
|
|
334
|
+
if (absolute !== root && !absolute.startsWith(root + path.sep)) {
|
|
335
|
+
return { reference, skipped: 'outside the package tree' }
|
|
336
|
+
}
|
|
337
|
+
let stats
|
|
338
|
+
try { stats = fs.lstatSync(absolute) } catch { return { reference, missing: true } }
|
|
339
|
+
if (stats.isSymbolicLink()) return { reference, skipped: 'symlink not followed' }
|
|
340
|
+
if (!stats.isFile()) return { reference, missing: true }
|
|
341
|
+
if (stats.size > SCRIPT_INSPECT_MAX_BYTES) return { reference, skipped: `larger than ${SCRIPT_INSPECT_MAX_BYTES} bytes` }
|
|
342
|
+
let text
|
|
343
|
+
try { text = fs.readFileSync(absolute, 'utf8') } catch { return { reference, skipped: 'unreadable' } }
|
|
344
|
+
if (text.includes('\0')) return { reference, skipped: 'binary content' }
|
|
345
|
+
return { reference, labels: scriptDangerLabels(text) }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function checkLifecycleScripts(manifest, dir) {
|
|
349
|
+
const scripts = manifest?.scripts && typeof manifest.scripts === 'object' && !Array.isArray(manifest.scripts) ? manifest.scripts : {}
|
|
350
|
+
const details = []
|
|
351
|
+
const findings = []
|
|
352
|
+
let present = 0
|
|
353
|
+
for (const name of LIFECYCLE_NAMES) {
|
|
354
|
+
const command = scripts[name]
|
|
355
|
+
if (typeof command !== 'string' || command.trim() === '') continue
|
|
356
|
+
present += 1
|
|
357
|
+
const evidence = [`package.json#scripts.${name}`]
|
|
358
|
+
const dangerous = DANGEROUS_SCRIPT.filter(rule => rule.pattern.test(command)).map(rule => rule.label)
|
|
359
|
+
const facts = dir ? collectScriptFacts(command, dir, scripts) : { references: [], missing: [] }
|
|
360
|
+
const missing = [...new Set(facts.missing)]
|
|
361
|
+
const contentFindings = []
|
|
362
|
+
const skipped = []
|
|
363
|
+
for (const reference of facts.references) {
|
|
364
|
+
const inspected = inspectInstallScript(reference, dir)
|
|
365
|
+
if (inspected.missing) missing.push(reference)
|
|
366
|
+
else if (inspected.skipped) skipped.push(`${reference}: ${inspected.skipped}`)
|
|
367
|
+
else if (inspected.labels.length > 0) contentFindings.push(`${reference}: ${inspected.labels.join(', ')}`)
|
|
368
|
+
}
|
|
369
|
+
if (name === 'prepare' && dangerous.length > 0) {
|
|
370
|
+
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))
|
|
371
|
+
findings.push('warn')
|
|
372
|
+
} else if (dangerous.length > 0) {
|
|
373
|
+
details.push(detail('C-15.1', 'FAIL', `scripts.${name} uses a dangerous form: ${dangerous.join('; ')}`, evidence))
|
|
374
|
+
findings.push('fail')
|
|
375
|
+
} else if (missing.length > 0) {
|
|
376
|
+
// `prepare` also runs for git/source installs, which pnpm gates behind
|
|
377
|
+
// allowBuilds; a missing prepare target is therefore WARN, not FAIL.
|
|
378
|
+
const level = name === 'prepare' ? 'WARN' : 'FAIL'
|
|
379
|
+
details.push(detail('C-15.3', level, `scripts.${name} references missing script(s): ${missing.join(', ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
|
|
380
|
+
findings.push(level === 'WARN' ? 'warn' : 'fail')
|
|
381
|
+
} else if (contentFindings.length > 0) {
|
|
382
|
+
const level = name === 'prepare' ? 'WARN' : 'FAIL'
|
|
383
|
+
details.push(detail('C-15.1', level, `referenced install script is dangerous: ${contentFindings.join('; ')}${name === 'prepare' ? ' (prepare is allowBuilds-gated)' : ''}`, evidence))
|
|
384
|
+
findings.push(level === 'WARN' ? 'warn' : 'fail')
|
|
385
|
+
} else if (skipped.length > 0) {
|
|
386
|
+
details.push(detail('C-15.2', 'WARN', `referenced install script was not inspected: ${skipped.join('; ')}`, evidence))
|
|
387
|
+
findings.push('warn')
|
|
388
|
+
} else {
|
|
389
|
+
details.push(detail('C-15.2', 'WARN', `lifecycle script scripts.${name} is present; install-time commands are not executed by the checker`, evidence))
|
|
390
|
+
if (facts.references.length > 0) {
|
|
391
|
+
details.push(detail('C-15.2', 'PASS', `inspected referenced script(s): ${facts.references.join(', ')}; no dangerous pattern found`))
|
|
392
|
+
}
|
|
393
|
+
findings.push('warn')
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (present === 0) {
|
|
397
|
+
details.push(detail('C-15.2', 'PASS', 'no preinstall/install/postinstall/prepare script is declared'))
|
|
398
|
+
}
|
|
399
|
+
const result = worst(findings.map(value => value === 'fail' ? 'FAIL' : 'WARN'))
|
|
400
|
+
return check({
|
|
401
|
+
id: 'DSC-15',
|
|
402
|
+
clause: 'C-15',
|
|
403
|
+
layer: 'L0',
|
|
404
|
+
result: findings.length === 0 ? 'PASS' : result,
|
|
405
|
+
coverage: 'full',
|
|
406
|
+
reason: details.filter(item => item.result === 'FAIL' || item.result === 'WARN').map(item => `${item.item}: ${item.reason}`).join(' | '),
|
|
407
|
+
details,
|
|
408
|
+
evidence: ['package.json#scripts'],
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// DSC-16: dynamic execution in the server entry closure
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
function allowsEvalContext(ancestors) {
|
|
417
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
418
|
+
const node = ancestors[index]
|
|
419
|
+
if (node.type === 'Property' && keyName(node) === 'allowsEval') return true
|
|
420
|
+
if (node.type === 'VariableDeclarator' && identifierName(node.id) === 'allowsEval') return true
|
|
421
|
+
if (node.type === 'AssignmentExpression' && memberPropertyName(node.left) === 'allowsEval') return true
|
|
422
|
+
}
|
|
423
|
+
return false
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isFunctionConstructor(node) {
|
|
427
|
+
if (node.type !== 'NewExpression' && node.type !== 'CallExpression') return false
|
|
428
|
+
return chainNames(node.callee).join('.') === 'Function'
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function functionConstructorExempt(node, ancestors) {
|
|
432
|
+
const args = node.arguments ?? []
|
|
433
|
+
if (args.length === 0) return true
|
|
434
|
+
if (args.every(arg => arg.type === 'Literal' && arg.value === '')) return true
|
|
435
|
+
return allowsEvalContext(ancestors)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function checkDynamicExecution(manifest, sources, entry, kind = null) {
|
|
439
|
+
if (kind === 'client') {
|
|
440
|
+
return check({
|
|
441
|
+
id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
|
|
442
|
+
reason: 'client-only package; server entry closure is not applicable',
|
|
443
|
+
details: [detail('C-16.4', 'NA', 'not-run: client package has no server entry', ['package.json#dsh.kind'])],
|
|
444
|
+
evidence: ['package.json#dsh.kind'],
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
if (!sources || sources.length === 0) {
|
|
448
|
+
return check({
|
|
449
|
+
id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
|
|
450
|
+
reason: 'no source files scanned; DSC-16 not-run',
|
|
451
|
+
details: [detail('C-16.4', 'NA', 'not-run: no source files', [])],
|
|
452
|
+
evidence: [],
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
if (serverSources(sources).length === 0) {
|
|
456
|
+
return check({
|
|
457
|
+
id: 'DSC-16', clause: 'C-16', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
|
|
458
|
+
reason: 'only client/browser sources were scanned; DSC-16 does not scan client code',
|
|
459
|
+
details: [detail('C-16.4', 'NA', 'not-run: no server source outside client/browser paths', [])],
|
|
460
|
+
evidence: [],
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
const closure = analyseClosure(sources, entry?.relative ?? null)
|
|
464
|
+
const details = [...closure.notes]
|
|
465
|
+
const dynamic = []
|
|
466
|
+
const subprocess = []
|
|
467
|
+
for (const relative of closure.visited) {
|
|
468
|
+
const parsed = parseModule(closure.byRelative.get(relative))
|
|
469
|
+
if (parsed.error) continue
|
|
470
|
+
walkAst(parsed.ast, (node, ancestors) => {
|
|
471
|
+
if (node.type === 'ImportDeclaration' && typeof node.source?.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
|
|
472
|
+
subprocess.push({ file: relative, line: lineOf(node), what: `import ${node.source.value}` })
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'require') {
|
|
476
|
+
const argument = node.arguments[0]
|
|
477
|
+
if (argument?.type === 'Literal' && typeof argument.value === 'string' && CHILD_PROCESS.test(argument.value)) {
|
|
478
|
+
subprocess.push({ file: relative, line: lineOf(node), what: `require('${argument.value}')` })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (node.type === 'ImportExpression' && node.source?.type === 'Literal' && typeof node.source.value === 'string' && CHILD_PROCESS.test(node.source.value)) {
|
|
482
|
+
subprocess.push({ file: relative, line: lineOf(node), what: `import('${node.source.value}')` })
|
|
483
|
+
}
|
|
484
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'eval') {
|
|
485
|
+
dynamic.push({ file: relative, line: lineOf(node), what: 'eval()', item: 'C-16.1' })
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
if (isFunctionConstructor(node)) {
|
|
489
|
+
if (!functionConstructorExempt(node, ancestors)) dynamic.push({ file: relative, line: lineOf(node), what: 'new Function()', item: 'C-16.1' })
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
if (node.type === 'CallExpression' || node.type === 'NewExpression') {
|
|
493
|
+
const names = chainNames(node.callee)
|
|
494
|
+
const joined = names.join('.')
|
|
495
|
+
if (/^vm\.(?:runInContext|runInNewContext|runInThisContext|compileFunction|createContext|Script)$/.test(joined)
|
|
496
|
+
|| (names.length === 2 && names[0] === 'vm' && names[1] === 'Script')) {
|
|
497
|
+
dynamic.push({ file: relative, line: lineOf(node), what: joined, item: 'C-16.2' })
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
if (names.length === 2 && names[1] === '_compile' && ['module', 'Module'].includes(names[0])) {
|
|
501
|
+
dynamic.push({ file: relative, line: lineOf(node), what: `${names[0]}._compile`, item: 'C-16.2' })
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
if (names.join('.') === 'process.binding') dynamic.push({ file: relative, line: lineOf(node), what: 'process.binding', item: 'C-16.2' })
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
const dynamicEvidence = dynamic.slice(0, 5).map(item => `${item.file}:${item.line}`)
|
|
509
|
+
const subprocessEvidence = subprocess.slice(0, 5).map(item => `${item.file}:${item.line}`)
|
|
510
|
+
for (const item of dynamic.slice(0, 8)) {
|
|
511
|
+
details.push(detail(item.item, 'FAIL', `dynamic execution (code:dynamic): ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
512
|
+
}
|
|
513
|
+
for (const item of subprocess.slice(0, 8)) {
|
|
514
|
+
details.push(detail('C-16.3', 'WARN', `subprocess capability observed: ${item.what} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
515
|
+
}
|
|
516
|
+
if (closure.packageImports.length > 0) {
|
|
517
|
+
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}`]))
|
|
518
|
+
}
|
|
519
|
+
for (const error of closure.parseErrors) {
|
|
520
|
+
details.push(detail('C-16.4', 'ERROR', `not-run: AST parse failed in ${error.file}:${error.line} (${error.message})`, [`${error.file}:${error.line}`]))
|
|
521
|
+
}
|
|
522
|
+
const coverage = closure.parseErrors.length > 0 || closure.packageImports.length > 0 || closure.unresolved.length > 0 ? 'partial' : 'full'
|
|
523
|
+
let result
|
|
524
|
+
let reason = ''
|
|
525
|
+
if (dynamic.length > 0) {
|
|
526
|
+
result = 'FAIL'
|
|
527
|
+
reason = `${dynamic.length} dynamic-execution site(s) in the server entry closure (code:dynamic)`
|
|
528
|
+
if (closure.parseErrors.length > 0) reason += `; ${closure.parseErrors.length} file(s) could not be parsed`
|
|
529
|
+
} else if (closure.parseErrors.length > 0) {
|
|
530
|
+
result = 'ERROR'
|
|
531
|
+
reason = `AST parse failed in ${closure.parseErrors.length} closure file(s); DSC-16 not-run`
|
|
532
|
+
} else if (subprocess.length > 0) {
|
|
533
|
+
result = 'WARN'
|
|
534
|
+
reason = `${subprocess.length} subprocess site(s) in the server entry closure`
|
|
535
|
+
} else {
|
|
536
|
+
result = 'PASS'
|
|
537
|
+
}
|
|
538
|
+
if (coverage === 'partial' && result === 'PASS') reason = `partial: ${closure.packageImports.length} package/unresolved import(s) were not scanned`
|
|
539
|
+
return check({
|
|
540
|
+
id: 'DSC-16',
|
|
541
|
+
clause: 'C-16',
|
|
542
|
+
layer: 'L0',
|
|
543
|
+
result,
|
|
544
|
+
coverage: result === 'ERROR' ? 'not-run' : coverage,
|
|
545
|
+
reason,
|
|
546
|
+
details,
|
|
547
|
+
evidence: dynamicEvidence.length > 0 ? dynamicEvidence : subprocessEvidence.length > 0 ? subprocessEvidence : [...closure.visited].slice(0, 3),
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// DSC-17: sensitive-file reads (AST argument backtracking)
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
function classifySecretPath(value) {
|
|
556
|
+
const normalized = String(value).replace(/\\/g, '/').toLowerCase()
|
|
557
|
+
if (/(?:^|\/)id_(?:rsa|ed25519|ecdsa|dsa)(?:[^/]*)?$/.test(normalized) || /(?:^|\/)\.ssh(?:\/|$)/.test(normalized)) return 'fail'
|
|
558
|
+
if (/(?:^|\/)\.aws(?:\/|$)|(?:^|\/)\.npmrc$|(?:^|\/)\.netrc$|(?:^|\/)\.pypi(?:rc)?$|(?:^|\/)\.pypirc$/.test(normalized)) return 'fail'
|
|
559
|
+
if (/(?:^|\/)tokens?(?:[-_.][^/]*)?$/.test(normalized)) return 'fail'
|
|
560
|
+
if (/(?:^|\/)\.dsh(?:\/|$)/.test(normalized) && /credential|auth|token|secret|key/.test(normalized)) return 'fail'
|
|
561
|
+
if (/\$dsh_home|\$home/.test(normalized) && /credential|token|secret/.test(normalized)) return 'fail'
|
|
562
|
+
if (/(?:^|\/)\.env(?:$|\.)/.test(normalized) && !/\.(?:example|sample|template|dist)$/.test(normalized)) return 'warn'
|
|
563
|
+
return null
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const HOME_ENV_NAMES = new Set(['HOME', 'USERPROFILE', 'DSH_HOME', 'XDG_CONFIG_HOME'])
|
|
567
|
+
const SECRET_STRONG = /(?:^|[^a-z0-9])(?:\.ssh|id_rsa|id_ed25519|credentials?(?![a-z0-9])|tokens?(?![a-z0-9-]))(?:[^a-z0-9]|$)/i
|
|
568
|
+
const SECRET_WEAK = /(?:^|[^a-z0-9])\.dsh(?:[^a-z0-9]|$)/i
|
|
569
|
+
const FUNCTION_SCOPE_NODES = new Set([
|
|
570
|
+
'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression',
|
|
571
|
+
'ObjectMethod', 'ClassMethod', 'ClassPrivateMethod', 'Program',
|
|
572
|
+
])
|
|
573
|
+
const FUNCTION_LIKE_NODES = new Set([...FUNCTION_SCOPE_NODES].filter(type => type !== 'Program'))
|
|
574
|
+
const PATH_LIKE_NAME = /(?:path|file|dir|folder|config|secret|cred|token|home)/i
|
|
575
|
+
|
|
576
|
+
function secretStrengthOfText(text) {
|
|
577
|
+
if (typeof text !== 'string' || text === '') return null
|
|
578
|
+
if (SECRET_STRONG.test(text)) return 'strong'
|
|
579
|
+
if (SECRET_WEAK.test(text)) return 'weak'
|
|
580
|
+
return null
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function strongerSecret(left, right) {
|
|
584
|
+
if (left === 'strong' || right === 'strong') return 'strong'
|
|
585
|
+
return left ?? right ?? null
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Literal text, template text or regex pattern; comments never reach the AST. */
|
|
589
|
+
function literalPathText(node) {
|
|
590
|
+
if (!node) return null
|
|
591
|
+
if (node.type === 'Literal') {
|
|
592
|
+
if (node.regex && typeof node.regex.pattern === 'string') return node.regex.pattern
|
|
593
|
+
if (typeof node.value === 'string') return node.value
|
|
594
|
+
}
|
|
595
|
+
if (node.type === 'TemplateElement' && node.value) {
|
|
596
|
+
return typeof node.value.cooked === 'string' ? node.value.cooked : (typeof node.value.raw === 'string' ? node.value.raw : null)
|
|
597
|
+
}
|
|
598
|
+
return null
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Only path-like literals count. A sentence such as "invalid credentials" is
|
|
603
|
+
* not a clue; `'.dsh'`, `'~/.ssh/id_rsa'`, `'credentials.json'` and a regex like
|
|
604
|
+
* `/^\.?credentials/` are.
|
|
605
|
+
*/
|
|
606
|
+
function isPathLikeSecretLiteral(node, text) {
|
|
607
|
+
const strength = secretStrengthOfText(text)
|
|
608
|
+
if (!strength) return null
|
|
609
|
+
if (node.type === 'Literal' && node.regex) return strength
|
|
610
|
+
const trimmed = String(text).trim()
|
|
611
|
+
if (trimmed === '') return null
|
|
612
|
+
if (trimmed.startsWith('.') || /[\\/]/.test(trimmed)) return strength
|
|
613
|
+
if (/^(?:credentials?|tokens?|id_rsa|id_ed25519|\.dsh|\.ssh)$/i.test(trimmed)) return strength
|
|
614
|
+
if (/^[\w@.-]*(?:credentials?|tokens?|id_rsa|id_ed25519)[\w@.-]*\.(?:json|ya?ml|txt|pem|key|env|ini|conf)$/i.test(trimmed)) return strength
|
|
615
|
+
return null
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Follow the argument through local assignments for a secret-path clue. */
|
|
619
|
+
function containsSecretSource(node, bindings, depth = 0, seen = new Set()) {
|
|
620
|
+
if (!node || depth > 4 || seen.has(node)) return null
|
|
621
|
+
const text = literalPathText(node)
|
|
622
|
+
if (text !== null) {
|
|
623
|
+
const strength = isPathLikeSecretLiteral(node, text)
|
|
624
|
+
if (strength) return strength
|
|
625
|
+
}
|
|
626
|
+
if (node.type === 'Identifier') {
|
|
627
|
+
const init = bindings.get(node.name)
|
|
628
|
+
if (!init || FUNCTION_LIKE_NODES.has(init.type)) return null
|
|
629
|
+
return containsSecretSource(init, bindings, depth + 1, new Set(seen).add(node))
|
|
630
|
+
}
|
|
631
|
+
let best = null
|
|
632
|
+
for (const key of Object.keys(node)) {
|
|
633
|
+
if (key === 'loc') continue
|
|
634
|
+
const value = node[key]
|
|
635
|
+
const children = Array.isArray(value) ? value : [value]
|
|
636
|
+
for (const child of children) {
|
|
637
|
+
if (!child || typeof child.type !== 'string' || FUNCTION_LIKE_NODES.has(child.type)) continue
|
|
638
|
+
const found = containsSecretSource(child, bindings, depth + 1, new Set(seen).add(node))
|
|
639
|
+
if (found === 'strong') return 'strong'
|
|
640
|
+
if (found === 'weak') best = 'weak'
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return best
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Home markers: os.homedir(), process.env.HOME/USERPROFILE/DSH_HOME/XDG_CONFIG_HOME. */
|
|
647
|
+
function containsHomeSource(node, bindings, depth = 0, seen = new Set()) {
|
|
648
|
+
if (!node || depth > 4 || seen.has(node)) return false
|
|
649
|
+
if (node.type === 'CallExpression') {
|
|
650
|
+
const names = chainNames(node.callee)
|
|
651
|
+
if (names[names.length - 1] === 'homedir') return true
|
|
652
|
+
}
|
|
653
|
+
if (node.type === 'MemberExpression') {
|
|
654
|
+
const property = memberPropertyName(node)
|
|
655
|
+
const object = node.object
|
|
656
|
+
if (property && HOME_ENV_NAMES.has(property) && object?.type === 'MemberExpression'
|
|
657
|
+
&& identifierName(object.object) === 'process' && memberPropertyName(object) === 'env') return true
|
|
658
|
+
}
|
|
659
|
+
if (node.type === 'Identifier') {
|
|
660
|
+
if (HOME_ENV_NAMES.has(node.name)) return true
|
|
661
|
+
const init = bindings.get(node.name)
|
|
662
|
+
if (!init || FUNCTION_LIKE_NODES.has(init.type)) return false
|
|
663
|
+
return containsHomeSource(init, bindings, depth + 1, new Set(seen).add(node))
|
|
664
|
+
}
|
|
665
|
+
for (const key of Object.keys(node)) {
|
|
666
|
+
if (key === 'loc') continue
|
|
667
|
+
const value = node[key]
|
|
668
|
+
const children = Array.isArray(value) ? value : [value]
|
|
669
|
+
for (const child of children) {
|
|
670
|
+
if (!child || typeof child.type !== 'string' || FUNCTION_LIKE_NODES.has(child.type)) continue
|
|
671
|
+
if (containsHomeSource(child, bindings, depth + 1, new Set(seen).add(node))) return true
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return false
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** A variable argument whose name suggests a path, e.g. configPath or secretFile. */
|
|
678
|
+
function argumentLooksPathLike(node) {
|
|
679
|
+
let found = false
|
|
680
|
+
walkAst(node, (child) => {
|
|
681
|
+
if (found) return
|
|
682
|
+
if (child.type === 'Identifier' && PATH_LIKE_NAME.test(child.name)) found = true
|
|
683
|
+
if (child.type === 'MemberExpression') {
|
|
684
|
+
const property = memberPropertyName(child)
|
|
685
|
+
if (property && PATH_LIKE_NAME.test(property)) found = true
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
return found
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isDshDirectoryPath(value) {
|
|
692
|
+
const normalized = String(value).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
693
|
+
return /(?:^|\/)\.dsh$/.test(normalized)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function securityScopeOf(ancestors, root) {
|
|
697
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
698
|
+
if (FUNCTION_SCOPE_NODES.has(ancestors[index].type)) return ancestors[index]
|
|
699
|
+
}
|
|
700
|
+
return root
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function isEgressCall(node) {
|
|
704
|
+
if (!node || node.type !== 'CallExpression') return false
|
|
705
|
+
const names = chainNames(node.callee)
|
|
706
|
+
const last = names[names.length - 1]
|
|
707
|
+
if (last === 'fetch') return true
|
|
708
|
+
return names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(last)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function isEnvEnumeration(node) {
|
|
712
|
+
return node.type === 'CallExpression'
|
|
713
|
+
&& node.callee.type === 'MemberExpression'
|
|
714
|
+
&& identifierName(node.callee.object) === 'Object'
|
|
715
|
+
&& memberPropertyName(node.callee) === 'keys'
|
|
716
|
+
&& node.arguments[0]?.type === 'MemberExpression'
|
|
717
|
+
&& identifierName(node.arguments[0].object) === 'process'
|
|
718
|
+
&& memberPropertyName(node.arguments[0]) === 'env'
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function hasEgressCall(ast) {
|
|
722
|
+
let found = false
|
|
723
|
+
walkAst(ast, (node) => {
|
|
724
|
+
if (!found && isEgressCall(node)) found = true
|
|
725
|
+
})
|
|
726
|
+
return found
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export function checkSensitiveFileReads(manifest, sources) {
|
|
730
|
+
if (!sources || sources.length === 0) {
|
|
731
|
+
return check({
|
|
732
|
+
id: 'DSC-17', clause: 'C-17', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: false,
|
|
733
|
+
reason: 'no source files scanned; DSC-17 not-run',
|
|
734
|
+
details: [detail('C-17.1', 'NA', 'not-run: no source files', [])],
|
|
735
|
+
evidence: [],
|
|
736
|
+
})
|
|
737
|
+
}
|
|
738
|
+
const details = []
|
|
739
|
+
const failures = []
|
|
740
|
+
const warnings = []
|
|
741
|
+
let parseErrors = 0
|
|
742
|
+
for (const source of serverSources(sources)) {
|
|
743
|
+
const parsed = parseModule(source)
|
|
744
|
+
if (parsed.error) { parseErrors += 1; continue }
|
|
745
|
+
let envEnumeration = false
|
|
746
|
+
const scopeFacts = new Map()
|
|
747
|
+
walkAst(parsed.ast, (node, ancestors) => {
|
|
748
|
+
const scope = securityScopeOf(ancestors, parsed.ast)
|
|
749
|
+
let facts = scopeFacts.get(scope)
|
|
750
|
+
if (!facts) {
|
|
751
|
+
facts = { secretLiteral: null, egress: false, reads: [] }
|
|
752
|
+
scopeFacts.set(scope, facts)
|
|
753
|
+
}
|
|
754
|
+
if (isEgressCall(node)) facts.egress = true
|
|
755
|
+
const literal = literalPathText(node)
|
|
756
|
+
if (literal !== null) {
|
|
757
|
+
const strength = isPathLikeSecretLiteral(node, literal)
|
|
758
|
+
if (strength) facts.secretLiteral = strongerSecret(facts.secretLiteral, strength)
|
|
759
|
+
}
|
|
760
|
+
if (isEnvEnumeration(node)) envEnumeration = true
|
|
761
|
+
if (node.type !== 'CallExpression' || node.arguments.length === 0) return
|
|
762
|
+
const api = node.callee.type === 'Identifier' ? node.callee.name : memberPropertyName(node.callee)
|
|
763
|
+
if (!READ_APIS.has(api)) return
|
|
764
|
+
const argument = node.arguments[0]
|
|
765
|
+
facts.reads.push({
|
|
766
|
+
api,
|
|
767
|
+
line: lineOf(node),
|
|
768
|
+
value: stringFromNode(argument, parsed.bindings),
|
|
769
|
+
homeDerived: containsHomeSource(argument, parsed.bindings),
|
|
770
|
+
secretDerived: containsSecretSource(argument, parsed.bindings),
|
|
771
|
+
pathNamed: argumentLooksPathLike(argument),
|
|
772
|
+
})
|
|
773
|
+
})
|
|
774
|
+
for (const facts of scopeFacts.values()) {
|
|
775
|
+
for (const read of facts.reads) {
|
|
776
|
+
const where = `${source.relative}:${read.line}`
|
|
777
|
+
if (read.value !== null && read.value !== '') {
|
|
778
|
+
const classification = classifySecretPath(read.value)
|
|
779
|
+
if (classification === 'fail') {
|
|
780
|
+
failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} reads a sensitive path at ${where}` })
|
|
781
|
+
continue
|
|
782
|
+
}
|
|
783
|
+
if (classification === 'warn') {
|
|
784
|
+
warnings.push({ file: source.relative, line: read.line, api: read.api, item: 'C-17.2', reason: `${read.api} reads a dotenv/environment surface at ${where}` })
|
|
785
|
+
continue
|
|
786
|
+
}
|
|
787
|
+
if (isDshDirectoryPath(read.value)) {
|
|
788
|
+
if (facts.egress && facts.secretLiteral) {
|
|
789
|
+
failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} lists the DSH home directory while the same function handles credential-like names and network egress at ${where}` })
|
|
790
|
+
} else {
|
|
791
|
+
warnings.push({ file: source.relative, line: read.line, api: read.api, item: 'C-17.2', reason: `${read.api} lists the DSH home directory at ${where}` })
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
continue
|
|
795
|
+
}
|
|
796
|
+
// Variable argument: approximate taint using the argument's assignment
|
|
797
|
+
// chain, a sibling credential-like literal, and network egress.
|
|
798
|
+
if (!read.homeDerived && !read.secretDerived) {
|
|
799
|
+
// A sibling literal alone is weak: only a path-named variable plus a
|
|
800
|
+
// credential literal (and egress for FAIL) is actionable.
|
|
801
|
+
if (!facts.secretLiteral || !read.pathNamed) continue
|
|
802
|
+
if (facts.egress && facts.secretLiteral === 'strong') {
|
|
803
|
+
failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} reads a path-named variable while the same function contains a credential-like literal and network egress at ${where}` })
|
|
804
|
+
} else {
|
|
805
|
+
warnings.push({ file: source.relative, line: read.line, api: read.api, item: 'C-17.2', reason: `${read.api} reads a path-named variable while the same function contains a credential-like literal at ${where}` })
|
|
806
|
+
}
|
|
807
|
+
continue
|
|
808
|
+
}
|
|
809
|
+
if (facts.egress) {
|
|
810
|
+
failures.push({ file: source.relative, line: read.line, api: read.api, reason: `${read.api} reads a home/secret-derived path and the same function performs network egress at ${where}` })
|
|
811
|
+
} else {
|
|
812
|
+
warnings.push({ file: source.relative, line: read.line, api: read.api, item: 'C-17.2', reason: `${read.api} uses a home/secret-derived path that cannot be fully resolved at ${where}` })
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (envEnumeration && hasEgressCall(parsed.ast)) {
|
|
817
|
+
warnings.push({ file: source.relative, line: 1, api: 'Object.keys(process.env)', item: 'C-17.2', reason: 'environment enumeration with egress' })
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
for (const item of failures.slice(0, 8)) {
|
|
821
|
+
details.push(detail('C-17.1', 'FAIL', item.reason, [`${item.file}:${item.line}`]))
|
|
822
|
+
}
|
|
823
|
+
for (const item of warnings.slice(0, 8)) {
|
|
824
|
+
details.push(detail(item.item ?? 'C-17.2', 'WARN', item.reason, [`${item.file}:${item.line}`]))
|
|
825
|
+
}
|
|
826
|
+
if (parseErrors > 0) {
|
|
827
|
+
details.push(detail('C-17.3', 'ERROR', `not-run: AST parse failed in ${parseErrors} source file(s)`, []))
|
|
828
|
+
}
|
|
829
|
+
if (failures.length === 0 && warnings.length === 0 && parseErrors === 0) {
|
|
830
|
+
details.push(detail('C-17.1', 'PASS', 'no sensitive-file read argument found in the scanned sources'))
|
|
831
|
+
}
|
|
832
|
+
let result
|
|
833
|
+
if (failures.length > 0) result = 'FAIL'
|
|
834
|
+
else if (parseErrors > 0) result = 'ERROR'
|
|
835
|
+
else if (warnings.length > 0) result = 'WARN'
|
|
836
|
+
else result = 'PASS'
|
|
837
|
+
return check({
|
|
838
|
+
id: 'DSC-17',
|
|
839
|
+
clause: 'C-17',
|
|
840
|
+
layer: 'L0',
|
|
841
|
+
result,
|
|
842
|
+
coverage: parseErrors > 0 ? 'partial' : 'full',
|
|
843
|
+
reason: failures.length > 0
|
|
844
|
+
? `${failures.length} sensitive-file read(s) in API arguments`
|
|
845
|
+
: parseErrors > 0
|
|
846
|
+
? `AST parse failed in ${parseErrors} file(s); DSC-17 partial`
|
|
847
|
+
: warnings.length > 0 ? `${warnings.length} dotenv/environment clue(s)` : '',
|
|
848
|
+
details,
|
|
849
|
+
evidence: failures.slice(0, 5).map(item => `${item.file}:${item.line}`),
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
// DSC-18: egress endpoints
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
function isPrivateIp(host) {
|
|
858
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false
|
|
859
|
+
const parts = host.split('.').map(Number)
|
|
860
|
+
if (parts.some(part => part > 255)) return false
|
|
861
|
+
if (parts[0] === 10 || parts[0] === 127) return true
|
|
862
|
+
if (parts[0] === 192 && parts[1] === 168) return true
|
|
863
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
|
|
864
|
+
if (parts[0] === 169 && parts[1] === 254) return true
|
|
865
|
+
return false
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function egressEndpointFrom(node, bindings) {
|
|
869
|
+
if (!node) return null
|
|
870
|
+
if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') return node.arguments[0]
|
|
871
|
+
return node
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function parseEndpoint(raw) {
|
|
875
|
+
try {
|
|
876
|
+
const url = new URL(raw)
|
|
877
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
878
|
+
return {
|
|
879
|
+
url,
|
|
880
|
+
normalized: `${url.protocol}//${url.host}${pathname}`,
|
|
881
|
+
host: url.hostname,
|
|
882
|
+
}
|
|
883
|
+
} catch {
|
|
884
|
+
return null
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
export function checkEgressEndpoints(manifest, sources) {
|
|
889
|
+
if (!sources || sources.length === 0) {
|
|
890
|
+
return check({
|
|
891
|
+
id: 'DSC-18', clause: 'C-18', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
|
|
892
|
+
reason: 'no source files scanned; DSC-18 not-run',
|
|
893
|
+
details: [detail('C-18.3', 'NA', 'not-run: no source files', [])],
|
|
894
|
+
evidence: [],
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
const endpoints = []
|
|
898
|
+
const dynamic = []
|
|
899
|
+
let parseErrors = 0
|
|
900
|
+
for (const source of serverSources(sources)) {
|
|
901
|
+
const parsed = parseModule(source)
|
|
902
|
+
if (parsed.error) { parseErrors += 1; continue }
|
|
903
|
+
walkAst(parsed.ast, (node) => {
|
|
904
|
+
let target = null
|
|
905
|
+
if (node.type === 'CallExpression') {
|
|
906
|
+
const names = chainNames(node.callee)
|
|
907
|
+
const last = names[names.length - 1]
|
|
908
|
+
if (last === 'fetch' || (names.length >= 2 && ['http', 'https'].includes(names[0]) && ['get', 'request'].includes(last)) || names[0] === 'axios') {
|
|
909
|
+
target = egressEndpointFrom(node.arguments[0], parsed.bindings)
|
|
910
|
+
}
|
|
911
|
+
} else if (node.type === 'NewExpression' && chainNames(node.callee).join('.') === 'WebSocket') {
|
|
912
|
+
target = egressEndpointFrom(node, parsed.bindings)
|
|
913
|
+
}
|
|
914
|
+
if (!target) return
|
|
915
|
+
const raw = stringFromNode(target, parsed.bindings)
|
|
916
|
+
if (raw === null) {
|
|
917
|
+
dynamic.push({ file: source.relative, line: lineOf(node) })
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
const endpoint = parseEndpoint(raw)
|
|
921
|
+
if (!endpoint) return
|
|
922
|
+
endpoints.push({ file: source.relative, line: lineOf(node), ...endpoint, raw })
|
|
923
|
+
})
|
|
924
|
+
}
|
|
925
|
+
const disclosure = manifest?.dsh?.disclosure
|
|
926
|
+
const declared = Array.isArray(disclosure?.endpoints) ? disclosure.endpoints : null
|
|
927
|
+
const declaredNormalized = declared === null ? null : new Set(declared.map(value => {
|
|
928
|
+
const parsed = parseEndpoint(String(value))
|
|
929
|
+
return parsed ? parsed.normalized : String(value)
|
|
930
|
+
}))
|
|
931
|
+
const details = []
|
|
932
|
+
const warnings = []
|
|
933
|
+
for (const endpoint of endpoints) {
|
|
934
|
+
const clues = []
|
|
935
|
+
if (endpoint.url.protocol !== 'https:') clues.push(`non-https scheme ${endpoint.url.protocol}`)
|
|
936
|
+
if (endpoint.url.username || endpoint.url.password) clues.push('userinfo in the URL')
|
|
937
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(endpoint.host) && !isPrivateIp(endpoint.host)) clues.push('public bare IP host')
|
|
938
|
+
if (SUSPICIOUS_HOST.test(endpoint.host) || /\.onion$/i.test(endpoint.host)) clues.push('webhook/paste/ngrok/onion host')
|
|
939
|
+
if (/^xn--/i.test(endpoint.host) || endpoint.host.includes('.xn--')) clues.push('punycode host')
|
|
940
|
+
if (declaredNormalized !== null && !declaredNormalized.has(endpoint.normalized)) clues.push('endpoint is not listed in dsh.disclosure.endpoints')
|
|
941
|
+
if (clues.length > 0) warnings.push({ ...endpoint, clues, item: clues.some(clue => clue.startsWith('endpoint is not listed')) ? 'C-18.2' : 'C-18.1' })
|
|
942
|
+
}
|
|
943
|
+
for (const item of warnings.slice(0, 8)) {
|
|
944
|
+
details.push(detail(item.item, 'WARN', `${item.clues.join('; ')} at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
945
|
+
}
|
|
946
|
+
for (const item of dynamic.slice(0, 3)) {
|
|
947
|
+
details.push(detail('C-18.3', 'NA', `not-run: dynamic endpoint argument at ${item.file}:${item.line}`, [`${item.file}:${item.line}`]))
|
|
948
|
+
}
|
|
949
|
+
if (parseErrors > 0) {
|
|
950
|
+
details.push(detail('C-18.3', 'NA', `not-run: AST parse failed in ${parseErrors} source file(s); endpoint coverage is partial`, []))
|
|
951
|
+
}
|
|
952
|
+
for (const endpoint of endpoints.slice(0, 10)) {
|
|
953
|
+
if (warnings.some(item => item.file === endpoint.file && item.line === endpoint.line)) continue
|
|
954
|
+
details.push(detail('C-18.3', 'PASS', `registered egress endpoint ${endpoint.normalized} at ${endpoint.file}:${endpoint.line}`, [`${endpoint.file}:${endpoint.line}`]))
|
|
955
|
+
}
|
|
956
|
+
if (endpoints.length === 0 && dynamic.length === 0) {
|
|
957
|
+
details.push(detail('C-18.3', 'PASS', 'no fetch/http request call site found'))
|
|
958
|
+
}
|
|
959
|
+
if (warnings.length === 0 && endpoints.length > 0) {
|
|
960
|
+
details.push(detail('C-18.3', 'PASS', `${endpoints.length} egress endpoint(s) registered; no suspicious host or scheme`))
|
|
961
|
+
}
|
|
962
|
+
const result = warnings.length > 0 ? 'WARN' : 'PASS'
|
|
963
|
+
return check({
|
|
964
|
+
id: 'DSC-18',
|
|
965
|
+
clause: 'C-18',
|
|
966
|
+
layer: 'L0',
|
|
967
|
+
result,
|
|
968
|
+
coverage: dynamic.length > 0 || parseErrors > 0 ? 'partial' : 'full',
|
|
969
|
+
advisory: true,
|
|
970
|
+
reason: warnings.length > 0 ? `${warnings.length} egress endpoint clue(s) (advisory)` : '',
|
|
971
|
+
details,
|
|
972
|
+
evidence: warnings.slice(0, 5).map(item => `${item.file}:${item.line}`),
|
|
973
|
+
})
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// DSC-19: opaque text / binary artifacts
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
function shannonEntropy(text) {
|
|
981
|
+
const counts = new Map()
|
|
982
|
+
for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1)
|
|
983
|
+
let entropy = 0
|
|
984
|
+
const length = text.length
|
|
985
|
+
for (const count of counts.values()) {
|
|
986
|
+
const probability = count / length
|
|
987
|
+
entropy -= probability * Math.log2(probability)
|
|
988
|
+
}
|
|
989
|
+
return entropy
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export function checkOpaqueArtifacts(dir, sources) {
|
|
993
|
+
if (!dir) {
|
|
994
|
+
return check({
|
|
995
|
+
id: 'DSC-19', clause: 'C-19', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
|
|
996
|
+
reason: 'no package directory; DSC-19 not-run',
|
|
997
|
+
details: [detail('C-19.1', 'NA', 'not-run: no package directory', [])],
|
|
998
|
+
evidence: [],
|
|
999
|
+
})
|
|
1000
|
+
}
|
|
1001
|
+
const inventory = inventoryFiles(dir)
|
|
1002
|
+
const details = []
|
|
1003
|
+
const binaries = []
|
|
1004
|
+
const opaque = []
|
|
1005
|
+
for (const file of inventory.files) {
|
|
1006
|
+
const extension = path.extname(file.relative).toLowerCase()
|
|
1007
|
+
if (BINARY_EXTENSIONS.has(extension)) {
|
|
1008
|
+
let buffer
|
|
1009
|
+
try { buffer = fs.readFileSync(file.file) } catch { continue }
|
|
1010
|
+
const sha256 = createHash('sha256').update(buffer).digest('hex')
|
|
1011
|
+
binaries.push({ relative: file.relative, size: file.size, sha256 })
|
|
1012
|
+
continue
|
|
1013
|
+
}
|
|
1014
|
+
if (!TEXT_EXTENSIONS.test(extension) || file.size > 512_000) continue
|
|
1015
|
+
let text
|
|
1016
|
+
try { text = fs.readFileSync(file.file, 'utf8') } catch { continue }
|
|
1017
|
+
if (/(?:\/\/[#@]\s*|\/\*[#@]?\s*)sourceMappingURL=/.test(text)) continue
|
|
1018
|
+
const longestLine = text.split(/\r?\n/).reduce((max, line) => Math.max(max, line.length), 0)
|
|
1019
|
+
if (longestLine > 20_000) {
|
|
1020
|
+
opaque.push({ relative: file.relative, clue: `single line of ${longestLine} chars without a sourcemap` })
|
|
1021
|
+
continue
|
|
1022
|
+
}
|
|
1023
|
+
if (/(?:^|[^A-Za-z0-9+/=])[A-Za-z0-9+/]{4000,}={0,2}(?:[^A-Za-z0-9+/=]|$)/.test(text)) {
|
|
1024
|
+
opaque.push({ relative: file.relative, clue: 'base64 block of 4000+ chars' })
|
|
1025
|
+
continue
|
|
1026
|
+
}
|
|
1027
|
+
if (text.length > 4096 && shannonEntropy(text) > 7.5) {
|
|
1028
|
+
opaque.push({ relative: file.relative, clue: `high entropy ${shannonEntropy(text).toFixed(2)} over ${text.length} chars` })
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
for (const item of opaque.slice(0, 8)) {
|
|
1032
|
+
details.push(detail('C-19.1', 'WARN', `opaque text clue: ${item.clue} in ${item.relative}`, [item.relative]))
|
|
1033
|
+
}
|
|
1034
|
+
for (const item of binaries.slice(0, 8)) {
|
|
1035
|
+
details.push(detail('C-19.2', 'WARN', `binary artifact ${item.relative} (${item.size} bytes, sha256:${item.sha256}) is recorded, not judged`, [item.relative]))
|
|
1036
|
+
}
|
|
1037
|
+
if (opaque.length === 0 && binaries.length === 0) {
|
|
1038
|
+
details.push(detail('C-19.1', 'PASS', 'no opaque text or binary artifact above the advisory thresholds'))
|
|
1039
|
+
}
|
|
1040
|
+
if (inventory.stats.truncated) {
|
|
1041
|
+
details.push(detail('C-19.1', 'NA', 'not-run: file inventory was truncated; opaque artifacts beyond the budget were not scanned', []))
|
|
1042
|
+
}
|
|
1043
|
+
const result = opaque.length > 0 || binaries.length > 0 ? 'WARN' : 'PASS'
|
|
1044
|
+
return check({
|
|
1045
|
+
id: 'DSC-19',
|
|
1046
|
+
clause: 'C-19',
|
|
1047
|
+
layer: 'L0',
|
|
1048
|
+
result,
|
|
1049
|
+
coverage: inventory.stats.truncated ? 'partial' : 'full',
|
|
1050
|
+
advisory: true,
|
|
1051
|
+
reason: result === 'WARN' ? `${opaque.length} opaque text clue(s), ${binaries.length} binary artifact(s) (advisory)` : '',
|
|
1052
|
+
details,
|
|
1053
|
+
evidence: [...opaque, ...binaries].slice(0, 5).map(item => item.relative),
|
|
1054
|
+
})
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ---------------------------------------------------------------------------
|
|
1058
|
+
// DSC-20: prompt-injection clues
|
|
1059
|
+
// ---------------------------------------------------------------------------
|
|
1060
|
+
|
|
1061
|
+
function propertyKeyOf(ancestors) {
|
|
1062
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
1063
|
+
const node = ancestors[index]
|
|
1064
|
+
if (node.type === 'Property') return keyName(node)
|
|
1065
|
+
if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') continue
|
|
1066
|
+
return null
|
|
1067
|
+
}
|
|
1068
|
+
return null
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function isRuleContext(ancestors, node) {
|
|
1072
|
+
const key = propertyKeyOf(ancestors)
|
|
1073
|
+
if (key && RULE_KEYS.has(key)) return true
|
|
1074
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
1075
|
+
const ancestor = ancestors[index]
|
|
1076
|
+
if (ancestor.type === 'NewExpression' && chainNames(ancestor.callee).join('.') === 'RegExp') return true
|
|
1077
|
+
if (ancestor.type === 'CallExpression' && ancestor.callee.type === 'MemberExpression') {
|
|
1078
|
+
const method = memberPropertyName(ancestor.callee)
|
|
1079
|
+
if (['test', 'match', 'matchAll', 'exec', 'includes', 'compile', 'replace', 'split'].includes(method)) return true
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return false
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function collectParameterNames(ast) {
|
|
1086
|
+
const names = new Set()
|
|
1087
|
+
walkAst(ast, (node) => {
|
|
1088
|
+
if (!['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type)) return
|
|
1089
|
+
for (const parameter of node.params ?? []) {
|
|
1090
|
+
if (parameter.type === 'Identifier') names.add(parameter.name)
|
|
1091
|
+
if (parameter.type === 'AssignmentPattern' && parameter.left?.type === 'Identifier') names.add(parameter.left.name)
|
|
1092
|
+
}
|
|
1093
|
+
})
|
|
1094
|
+
return names
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function isExternalExpression(node, bindings, parameters, depth = 0, seen = new Set()) {
|
|
1098
|
+
if (!node || depth > 4 || seen.has(node)) return false
|
|
1099
|
+
if (node.type === 'Identifier') {
|
|
1100
|
+
if (parameters.has(node.name)) return true
|
|
1101
|
+
const init = bindings.get(node.name)
|
|
1102
|
+
if (!init) return false
|
|
1103
|
+
return isExternalExpression(init, bindings, parameters, depth + 1, new Set(seen).add(node))
|
|
1104
|
+
}
|
|
1105
|
+
if (node.type === 'AwaitExpression') return isExternalExpression(node.argument, bindings, parameters, depth + 1, new Set(seen).add(node))
|
|
1106
|
+
if (node.type === 'TemplateLiteral') {
|
|
1107
|
+
return node.expressions.some(expression => isExternalExpression(expression, bindings, parameters, depth + 1, new Set(seen).add(node)))
|
|
1108
|
+
}
|
|
1109
|
+
if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') {
|
|
1110
|
+
return isExternalExpression(node.left, bindings, parameters, depth + 1, new Set(seen).add(node))
|
|
1111
|
+
|| isExternalExpression(node.right, bindings, parameters, depth + 1, new Set(seen).add(node))
|
|
1112
|
+
}
|
|
1113
|
+
if (node.type === 'MemberExpression') {
|
|
1114
|
+
return ['tools', 'toolResult', 'messages', 'results'].includes(memberPropertyName(node))
|
|
1115
|
+
}
|
|
1116
|
+
if (node.type === 'CallExpression') {
|
|
1117
|
+
const names = chainNames(node.callee)
|
|
1118
|
+
const joined = names.join('.').toLowerCase()
|
|
1119
|
+
return /fetch|request|tool|invoke|call|read|query|search|message|completion/.test(joined)
|
|
1120
|
+
}
|
|
1121
|
+
return false
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
export function checkPromptInjectionClues(manifest, sources) {
|
|
1125
|
+
if (!sources || sources.length === 0) {
|
|
1126
|
+
return check({
|
|
1127
|
+
id: 'DSC-20', clause: 'C-20', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
|
|
1128
|
+
reason: 'no source files scanned; DSC-20 not-run',
|
|
1129
|
+
details: [detail('C-20.1', 'NA', 'not-run: no source files', [])],
|
|
1130
|
+
evidence: [],
|
|
1131
|
+
})
|
|
1132
|
+
}
|
|
1133
|
+
const details = []
|
|
1134
|
+
const clues = []
|
|
1135
|
+
for (const source of serverSources(sources)) {
|
|
1136
|
+
if (RULE_FILE.test(source.relative)) continue
|
|
1137
|
+
const parsed = parseModule(source)
|
|
1138
|
+
if (parsed.error) continue
|
|
1139
|
+
const parameters = collectParameterNames(parsed.ast)
|
|
1140
|
+
walkAst(parsed.ast, (node, ancestors) => {
|
|
1141
|
+
if (node.type !== 'CallExpression') return
|
|
1142
|
+
const names = chainNames(node.callee)
|
|
1143
|
+
if (!names.includes('systemPrompt')) return
|
|
1144
|
+
const method = names[names.length - 1]
|
|
1145
|
+
if (!['section', 'context', 'variable'].includes(method)) return
|
|
1146
|
+
const argument = node.arguments[0]
|
|
1147
|
+
if (!argument) return
|
|
1148
|
+
if (isRuleContext(ancestors, node)) return
|
|
1149
|
+
const property = propertyKeyOf(ancestors)
|
|
1150
|
+
if (property && DOC_KEYS.has(property)) return
|
|
1151
|
+
const literal = argument.type === 'TemplateLiteral'
|
|
1152
|
+
? stringFromNode(argument, parsed.bindings)
|
|
1153
|
+
: literalStringValue(argument)
|
|
1154
|
+
if (literal !== null) {
|
|
1155
|
+
if (INSTRUCTION_PATTERN.test(literal)) {
|
|
1156
|
+
clues.push({ file: source.relative, line: lineOf(node), what: 'instruction-like systemPrompt literal' })
|
|
1157
|
+
}
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
if (isExternalExpression(argument, parsed.bindings, parameters)) {
|
|
1161
|
+
clues.push({ file: source.relative, line: lineOf(node), what: 'systemPrompt argument derives from external input' })
|
|
1162
|
+
}
|
|
1163
|
+
})
|
|
1164
|
+
}
|
|
1165
|
+
for (const clue of clues.slice(0, 8)) {
|
|
1166
|
+
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}`]))
|
|
1167
|
+
}
|
|
1168
|
+
if (clues.length === 0) details.push(detail('C-20.1', 'PASS', 'no prompt-surface clue found (instruction-like literal or external systemPrompt argument)'))
|
|
1169
|
+
return check({
|
|
1170
|
+
id: 'DSC-20',
|
|
1171
|
+
clause: 'C-20',
|
|
1172
|
+
layer: 'L0',
|
|
1173
|
+
result: clues.length > 0 ? 'WARN' : 'PASS',
|
|
1174
|
+
coverage: 'full',
|
|
1175
|
+
advisory: true,
|
|
1176
|
+
reason: clues.length > 0 ? `${clues.length} prompt-injection clue(s); semantic intent is not judged` : '',
|
|
1177
|
+
details,
|
|
1178
|
+
evidence: clues.slice(0, 5).map(clue => `${clue.file}:${clue.line}`),
|
|
1179
|
+
})
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ---------------------------------------------------------------------------
|
|
1183
|
+
// DSC-21: supply-chain name similarity
|
|
1184
|
+
// ---------------------------------------------------------------------------
|
|
1185
|
+
|
|
1186
|
+
const KNOWN_SEEDS = [
|
|
1187
|
+
'@deepseek-ai/dsh', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-app-boot',
|
|
1188
|
+
'@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/dsh-credentials',
|
|
1189
|
+
'@deepseek-ai/dsh-credentials-local', '@deepseek-ai/dsh-session',
|
|
1190
|
+
'@deepseek-ai/dsh-system-prompt', '@deepseek-ai/dsh-fs', '@deepseek-ai/dsh-fs-sandbox',
|
|
1191
|
+
'@deepseek-ai/dsh-sandbox', '@deepseek-ai/dsh-interaction', '@deepseek-ai/dsh-llm',
|
|
1192
|
+
'@deepseek-ai/dsh-web', '@deepseek-ai/dsh-client', 'cordis',
|
|
1193
|
+
'@cordisjs/core', 'schemastery', 'cosmokit',
|
|
1194
|
+
]
|
|
1195
|
+
const KNOWN_SET = new Set(KNOWN_SEEDS)
|
|
1196
|
+
const SEED_BASENAMES = [...new Set(KNOWN_SEEDS.map(name => name.split('/').pop()))]
|
|
1197
|
+
|
|
1198
|
+
function editDistanceWithin(left, right, limit) {
|
|
1199
|
+
if (Math.abs(left.length - right.length) > limit) return false
|
|
1200
|
+
const previous = new Array(right.length + 1).fill(0).map((_, index) => index)
|
|
1201
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
1202
|
+
let diagonal = previous[0]
|
|
1203
|
+
previous[0] = i
|
|
1204
|
+
let rowMin = previous[0]
|
|
1205
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
1206
|
+
const above = previous[j]
|
|
1207
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1
|
|
1208
|
+
previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + cost)
|
|
1209
|
+
diagonal = above
|
|
1210
|
+
rowMin = Math.min(rowMin, previous[j])
|
|
1211
|
+
}
|
|
1212
|
+
if (rowMin > limit) return false
|
|
1213
|
+
}
|
|
1214
|
+
return previous[right.length] <= limit
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function normalizeHomoglyph(value) {
|
|
1218
|
+
return String(value).toLowerCase().replace(/0/g, 'o').replace(/1/g, 'l').replace(/rn/g, 'm')
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function scopeOf(name) {
|
|
1222
|
+
return name.startsWith('@') ? name.split('/')[0] : null
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
export function checkSupplyChain(manifest) {
|
|
1226
|
+
const dependencies = {
|
|
1227
|
+
...(manifest?.dependencies && typeof manifest.dependencies === 'object' ? manifest.dependencies : {}),
|
|
1228
|
+
...(manifest?.optionalDependencies && typeof manifest.optionalDependencies === 'object' ? manifest.optionalDependencies : {}),
|
|
1229
|
+
...(manifest?.peerDependencies && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {}),
|
|
1230
|
+
}
|
|
1231
|
+
const names = Object.keys(dependencies)
|
|
1232
|
+
const details = []
|
|
1233
|
+
const findings = []
|
|
1234
|
+
let knownCount = 0
|
|
1235
|
+
for (const name of names) {
|
|
1236
|
+
if (KNOWN_SET.has(name)) { knownCount += 1; continue }
|
|
1237
|
+
const scope = scopeOf(name)
|
|
1238
|
+
if (scope && scope !== '@deepseek-ai' && (editDistanceWithin(scope, '@deepseek-ai', 1) || normalizeHomoglyph(scope) === normalizeHomoglyph('@deepseek-ai'))) {
|
|
1239
|
+
findings.push({ name, item: 'C-21.2', why: `dependency scope ${scope} is a near-miss of the official @deepseek-ai scope` })
|
|
1240
|
+
continue
|
|
1241
|
+
}
|
|
1242
|
+
const basename = name.split('/').pop()
|
|
1243
|
+
if (!/dsh/i.test(basename) && !/dsh/i.test(scope ?? '')) continue
|
|
1244
|
+
const similar = SEED_BASENAMES.find(seed => seed !== basename && editDistanceWithin(basename, seed, 1))
|
|
1245
|
+
if (similar) findings.push({ name, item: 'C-21.1', why: `dependency ${name} is one edit away from known package ${similar}` })
|
|
1246
|
+
}
|
|
1247
|
+
for (const finding of findings.slice(0, 8)) {
|
|
1248
|
+
details.push(detail(finding.item, 'WARN', finding.why, [`package.json#dependencies.${finding.name}`]))
|
|
1249
|
+
}
|
|
1250
|
+
if (names.length === 0) {
|
|
1251
|
+
details.push(detail('C-21.1', 'PASS', 'no runtime dependencies declared'))
|
|
1252
|
+
return check({
|
|
1253
|
+
id: 'DSC-21', clause: 'C-21', layer: 'L0', result: 'PASS', coverage: 'full', advisory: false,
|
|
1254
|
+
reason: '', details, evidence: ['package.json#dependencies'],
|
|
1255
|
+
})
|
|
1256
|
+
}
|
|
1257
|
+
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', []))
|
|
1258
|
+
if (findings.length === 0) {
|
|
1259
|
+
details.push(detail('C-21.1', 'PASS', `${names.length} dependency name(s) checked; ${knownCount} exact known name(s)`))
|
|
1260
|
+
}
|
|
1261
|
+
return check({
|
|
1262
|
+
id: 'DSC-21',
|
|
1263
|
+
clause: 'C-21',
|
|
1264
|
+
layer: 'L0',
|
|
1265
|
+
result: findings.length > 0 ? 'WARN' : 'PASS',
|
|
1266
|
+
coverage: 'partial',
|
|
1267
|
+
advisory: false,
|
|
1268
|
+
reason: findings.length > 0 ? `${findings.length} supply-chain name clue(s)` : '',
|
|
1269
|
+
details,
|
|
1270
|
+
evidence: findings.slice(0, 5).map(finding => `package.json#dependencies.${finding.name}`),
|
|
1271
|
+
})
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ---------------------------------------------------------------------------
|
|
1275
|
+
// DSC-22: provenance / integrity (advisory only)
|
|
1276
|
+
// ---------------------------------------------------------------------------
|
|
1277
|
+
|
|
1278
|
+
export function checkProvenance(manifest) {
|
|
1279
|
+
const integrity = manifest?.dist?.integrity
|
|
1280
|
+
const attestation = manifest?.dsh?.attestation
|
|
1281
|
+
const signature = manifest?.dsh?.signature
|
|
1282
|
+
const details = []
|
|
1283
|
+
if (typeof integrity === 'string' && integrity.trim() !== '') {
|
|
1284
|
+
details.push(detail('C-22.1', 'PASS', 'dist.integrity is present; this is integrity-only and does not prove provenance', ['package.json#dist.integrity']))
|
|
1285
|
+
return check({
|
|
1286
|
+
id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'PASS', coverage: 'full', advisory: true,
|
|
1287
|
+
reason: '', details, evidence: ['package.json#dist.integrity'],
|
|
1288
|
+
})
|
|
1289
|
+
}
|
|
1290
|
+
if ((typeof attestation === 'object' && attestation !== null) || (typeof signature === 'object' && signature !== null) || typeof attestation === 'string' || typeof signature === 'string') {
|
|
1291
|
+
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']))
|
|
1292
|
+
return check({
|
|
1293
|
+
id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'PASS', coverage: 'full', advisory: true,
|
|
1294
|
+
reason: '', details, evidence: ['package.json#dsh.attestation'],
|
|
1295
|
+
})
|
|
1296
|
+
}
|
|
1297
|
+
details.push(detail('C-22.1', 'NA', 'not-run: no registry metadata or attestation supplied; provenance is not judged', []))
|
|
1298
|
+
return check({
|
|
1299
|
+
id: 'DSC-22', clause: 'C-22', layer: 'L0', result: 'NA', coverage: 'not-run', advisory: true,
|
|
1300
|
+
reason: 'no registry metadata/attestation input; not-run',
|
|
1301
|
+
details, evidence: [],
|
|
1302
|
+
})
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ---------------------------------------------------------------------------
|
|
1306
|
+
// aggregate
|
|
1307
|
+
// ---------------------------------------------------------------------------
|
|
1308
|
+
|
|
1309
|
+
function unavailable(reason) {
|
|
1310
|
+
return SECURITY_IDS.map(id => check({
|
|
1311
|
+
id,
|
|
1312
|
+
clause: SECURITY_CLAUSE[id],
|
|
1313
|
+
layer: 'L0',
|
|
1314
|
+
result: 'ERROR',
|
|
1315
|
+
coverage: 'not-run',
|
|
1316
|
+
advisory: SECURITY_ADVISORY.has(id),
|
|
1317
|
+
reason,
|
|
1318
|
+
details: [detail(SECURITY_CLAUSE[id], 'ERROR', `not-run: ${reason}`, [])],
|
|
1319
|
+
evidence: [],
|
|
1320
|
+
}))
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** Run all eight security checks. Never throws: a failure becomes not-run rows. */
|
|
1324
|
+
export function runSecurityChecks({ dir, manifest, sources = [], entry = null, kind = null } = {}) {
|
|
1325
|
+
if (!manifest) return unavailable('manifest unavailable')
|
|
1326
|
+
const checks = []
|
|
1327
|
+
const run = (id, factory) => {
|
|
1328
|
+
try {
|
|
1329
|
+
checks.push(factory())
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
checks.push(check({
|
|
1332
|
+
id,
|
|
1333
|
+
clause: SECURITY_CLAUSE[id],
|
|
1334
|
+
layer: 'L0',
|
|
1335
|
+
result: 'ERROR',
|
|
1336
|
+
coverage: 'not-run',
|
|
1337
|
+
advisory: SECURITY_ADVISORY.has(id),
|
|
1338
|
+
reason: `checker error: ${error?.message ?? error}`,
|
|
1339
|
+
details: [detail(SECURITY_CLAUSE[id], 'ERROR', `not-run: ${error?.message ?? error}`, [])],
|
|
1340
|
+
evidence: [],
|
|
1341
|
+
}))
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
run('DSC-15', () => checkLifecycleScripts(manifest, dir))
|
|
1345
|
+
run('DSC-16', () => checkDynamicExecution(manifest, sources, entry, kind))
|
|
1346
|
+
run('DSC-17', () => checkSensitiveFileReads(manifest, sources))
|
|
1347
|
+
run('DSC-18', () => checkEgressEndpoints(manifest, sources))
|
|
1348
|
+
run('DSC-19', () => checkOpaqueArtifacts(dir, sources))
|
|
1349
|
+
run('DSC-20', () => checkPromptInjectionClues(manifest, sources))
|
|
1350
|
+
run('DSC-21', () => checkSupplyChain(manifest))
|
|
1351
|
+
run('DSC-22', () => checkProvenance(manifest))
|
|
1352
|
+
return checks
|
|
1353
|
+
}
|