@shidesheng0218/dsh-plugin-lint 0.0.0-stage → 0.4.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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +734 -2
- package/bin/dsh-plugin-analyze.mjs +378 -0
- package/bin/dsh-plugin-lint.mjs +381 -0
- package/bin/dsh-plugin-matrix.mjs +78 -0
- package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
- package/package.json +62 -4
- package/schema/dsh-manifest.schema.json +134 -0
- package/scripts/lib/probe.mjs +34 -0
- package/scripts/probe-boot.mjs +123 -0
- package/src/composition.js +1 -0
- package/src/core/baseline.js +98 -0
- package/src/core/changed.js +41 -0
- package/src/core/load.js +145 -0
- package/src/core/mask.js +207 -0
- package/src/core/pack.js +95 -0
- package/src/core/packlist.js +186 -0
- package/src/core/peer.js +39 -0
- package/src/core/profile-scan.js +121 -0
- package/src/core/report.js +174 -0
- package/src/core/rules.js +581 -0
- package/src/core/semver.js +192 -0
- package/src/core/tarball.js +90 -0
- package/src/data/event-types.json +393 -0
- package/src/experimental/conflicts.js +315 -0
- package/src/experimental/index.js +4 -0
- package/src/experimental/performance.js +515 -0
- package/src/experimental/security.js +462 -0
- package/src/index.js +74 -0
- package/src/load.js +1 -0
- package/src/mask.js +1 -0
- package/src/matrix/cache.js +19 -0
- package/src/matrix/cell.js +79 -0
- package/src/matrix/cells.js +58 -0
- package/src/matrix/conflicts.js +210 -0
- package/src/matrix/export.js +155 -0
- package/src/matrix/index.js +71 -0
- package/src/matrix/manifest.js +17 -0
- package/src/matrix/migrate.js +41 -0
- package/src/matrix/run.js +241 -0
- package/src/matrix/sanitize.js +42 -0
- package/src/matrix/schema.js +178 -0
- package/src/matrix/site.js +61 -0
- package/src/matrix/sync.js +72 -0
- package/src/matrix/verify.js +152 -0
- package/src/performance.js +1 -0
- package/src/probe/artifact-resolver.js +161 -0
- package/src/probe/classify.js +8 -0
- package/src/probe/dsh-adapter.js +212 -0
- package/src/probe/environment/docker.js +270 -0
- package/src/probe/environment/host-unsafe.js +39 -0
- package/src/probe/environment/materialize.js +22 -0
- package/src/probe/environment.js +50 -0
- package/src/probe/installed-artifact.js +17 -0
- package/src/probe/probe.js +278 -0
- package/src/report.js +1 -0
- package/src/rules.js +1 -0
- package/src/security.js +1 -0
- package/src/shared/errors.js +7 -0
- package/src/shared/hash.js +31 -0
- package/src/shared/status.js +12 -0
- package/src/shared/validate.js +14 -0
- package/src/shared/version.js +4 -0
- package/src/upgrade/compatibility.js +14 -0
- package/src/upgrade/planner.js +119 -0
- package/src/upgrade/profile.js +82 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security deep analysis rules — EXPERIMENTAL.
|
|
3
|
+
*
|
|
4
|
+
* These heuristics go beyond capability disclosure and look at how a capability
|
|
5
|
+
* appears to be used. They are static pattern matches over source text, not
|
|
6
|
+
* runtime observation: every rule caps at warning level, a finding is a review
|
|
7
|
+
* hint rather than a verdict, and a clean run says nothing about safety.
|
|
8
|
+
*
|
|
9
|
+
* @module security
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { EXPERIMENTAL_SCHEMA_VERSION } from '../shared/version.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Security-focused rule definitions for the lint engine.
|
|
16
|
+
* These extend the base RULES with deeper (experimental) analysis.
|
|
17
|
+
*/
|
|
18
|
+
export const SECURITY_RULES = [
|
|
19
|
+
{
|
|
20
|
+
id: 'S1',
|
|
21
|
+
level: 'warn',
|
|
22
|
+
title: 'session data sent to external domains',
|
|
23
|
+
category: 'data-exfiltration',
|
|
24
|
+
check(ctx, options, push) {
|
|
25
|
+
for (const file of ctx.jsFiles) {
|
|
26
|
+
const exfiltrations = detectDataExfiltration(file, ['session', 'agent', 'messages']);
|
|
27
|
+
for (const ex of exfiltrations) {
|
|
28
|
+
push({
|
|
29
|
+
file: file.path,
|
|
30
|
+
line: ex.line,
|
|
31
|
+
message: `Potentially sending ${ex.dataType} to external URL: ${ex.url}`,
|
|
32
|
+
evidence: ex.snippet,
|
|
33
|
+
hint: 'Review whether sensitive session data should be transmitted externally. Ensure proper user consent and encryption.',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
id: 'S2',
|
|
42
|
+
level: 'warn',
|
|
43
|
+
title: 'credentials stored in localStorage or sessionStorage',
|
|
44
|
+
category: 'credential-handling',
|
|
45
|
+
check(ctx, options, push) {
|
|
46
|
+
for (const file of ctx.jsFiles) {
|
|
47
|
+
const storagePattern = /(localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*['"]([^'"]*(?:token|key|secret|password|auth|credential)[^'"]*)['"]/gi;
|
|
48
|
+
let m;
|
|
49
|
+
while ((m = storagePattern.exec(file.masked)) !== null) {
|
|
50
|
+
push({
|
|
51
|
+
file: file.path,
|
|
52
|
+
line: lineOfIndex(file.masked, m.index),
|
|
53
|
+
message: `Storing sensitive data (${m[2]}) in ${m[1]}`,
|
|
54
|
+
evidence: m[0],
|
|
55
|
+
hint: 'Browser storage is not encrypted and accessible to all scripts. Use secure credential storage APIs or context-provided auth mechanisms.',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
id: 'S3',
|
|
64
|
+
level: 'warn',
|
|
65
|
+
title: 'eval() or Function() constructor used',
|
|
66
|
+
category: 'code-injection',
|
|
67
|
+
check(ctx, options, push) {
|
|
68
|
+
for (const file of ctx.jsFiles) {
|
|
69
|
+
// The structural view blanks string interiors, so a documented "eval(" in
|
|
70
|
+
// a string literal is not mistaken for a call site.
|
|
71
|
+
const source = file.structural ?? file.masked ?? file.text;
|
|
72
|
+
const evalPattern = /\beval\s*\(/g;
|
|
73
|
+
const functionPattern = /\bnew\s+Function\s*\(/g;
|
|
74
|
+
|
|
75
|
+
let m;
|
|
76
|
+
while ((m = evalPattern.exec(source)) !== null) {
|
|
77
|
+
push({
|
|
78
|
+
file: file.path,
|
|
79
|
+
line: lineOfIndex(file.masked, m.index),
|
|
80
|
+
message: 'Use of eval() detected',
|
|
81
|
+
evidence: extractContext(file.text, m.index, 50),
|
|
82
|
+
hint: 'eval() executes arbitrary code and can be a security risk. Consider safer alternatives like JSON.parse() or structured data processing.',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
while ((m = functionPattern.exec(source)) !== null) {
|
|
87
|
+
push({
|
|
88
|
+
file: file.path,
|
|
89
|
+
line: lineOfIndex(file.masked, m.index),
|
|
90
|
+
message: 'Use of Function() constructor detected',
|
|
91
|
+
evidence: extractContext(file.text, m.index, 50),
|
|
92
|
+
hint: 'Dynamic function construction can execute arbitrary code. Consider structured alternatives.',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
id: 'S4',
|
|
101
|
+
level: 'warn',
|
|
102
|
+
title: 'innerHTML used with unsanitized data',
|
|
103
|
+
category: 'xss',
|
|
104
|
+
check(ctx, options, push) {
|
|
105
|
+
const clientFile = ctx.jsFiles.find((f) => f.path === ctx.entries?.client);
|
|
106
|
+
if (!clientFile) return;
|
|
107
|
+
|
|
108
|
+
// The masked view keeps string literals intact but blanks comments, so a
|
|
109
|
+
// documented example in a comment is not a finding.
|
|
110
|
+
const source = clientFile.masked ?? clientFile.text;
|
|
111
|
+
const innerHTMLPattern = /\.innerHTML\s*=\s*([^;]+)/g;
|
|
112
|
+
let m;
|
|
113
|
+
|
|
114
|
+
while ((m = innerHTMLPattern.exec(source)) !== null) {
|
|
115
|
+
const assignment = m[1].trim();
|
|
116
|
+
const isSanitized = /\b(?:DOMPurify|sanitize|escapeHtml)\b/.test(assignment);
|
|
117
|
+
if (isSanitized) continue;
|
|
118
|
+
// A plain string literal is safe; a template literal is safe only when it
|
|
119
|
+
// has no interpolation. Everything else is dynamic and worth a look.
|
|
120
|
+
const plainString = /^(?:'[^']*'|"[^"]*")\s*$/.test(assignment);
|
|
121
|
+
if (plainString) continue;
|
|
122
|
+
const template = /^`([\s\S]*)`\s*$/.exec(assignment);
|
|
123
|
+
if (template && !/\$\{/.test(template[1])) continue;
|
|
124
|
+
push({
|
|
125
|
+
file: clientFile.path,
|
|
126
|
+
line: lineOfIndex(clientFile.text, m.index),
|
|
127
|
+
message: 'innerHTML assigned a value that is not a static string',
|
|
128
|
+
evidence: m[0],
|
|
129
|
+
hint: 'Use textContent for plain text, or sanitize HTML with a library like DOMPurify before assigning to innerHTML.',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
id: 'S5',
|
|
137
|
+
level: 'info',
|
|
138
|
+
title: 'makes network requests to external domains',
|
|
139
|
+
category: 'network-integrity',
|
|
140
|
+
check(ctx, options, push) {
|
|
141
|
+
// The DSH manifest has no declared-capability field, so this rule cannot
|
|
142
|
+
// compare against a declaration. It reports what the code does, as an
|
|
143
|
+
// inventory entry, and says nothing about whether that use is appropriate.
|
|
144
|
+
const seen = new Map();
|
|
145
|
+
const domainPattern = /(?:fetch|axios)\s*(?:\.\s*(?:get|post|put|delete))?\s*\(\s*['"`](https?:\/\/[^'"`]+)['"`]/g;
|
|
146
|
+
|
|
147
|
+
for (const file of ctx.jsFiles) {
|
|
148
|
+
let m;
|
|
149
|
+
while ((m = domainPattern.exec(file.masked ?? file.text)) !== null) {
|
|
150
|
+
try {
|
|
151
|
+
const url = new URL(m[1]);
|
|
152
|
+
if (!seen.has(url.hostname)) {
|
|
153
|
+
seen.set(url.hostname, { file: file.path, line: lineOfIndex(file.text, m.index), url: m[1] });
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Invalid URL
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const [domain, site] of [...seen.entries()].slice(0, 10)) {
|
|
162
|
+
push({
|
|
163
|
+
file: site.file,
|
|
164
|
+
line: site.line,
|
|
165
|
+
message: 'calls ' + site.url,
|
|
166
|
+
evidence: domain,
|
|
167
|
+
hint: 'Observed literal URL in the code. This is an inventory entry, not a declaration check: the DSH manifest has no dsh.capabilities field.',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
id: 'S6',
|
|
175
|
+
level: 'warn',
|
|
176
|
+
title: 'shell command injection risk',
|
|
177
|
+
category: 'command-injection',
|
|
178
|
+
check(ctx, options, push) {
|
|
179
|
+
for (const file of ctx.jsFiles) {
|
|
180
|
+
// Check for exec/spawn with string interpolation
|
|
181
|
+
const execPatterns = [
|
|
182
|
+
/exec\s*\(\s*`[^`]*\$\{/g,
|
|
183
|
+
/spawn\s*\(\s*`[^`]*\$\{/g,
|
|
184
|
+
/exec\s*\(\s*['"][^'"]*\+/g,
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
for (const pattern of execPatterns) {
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = pattern.exec(file.masked)) !== null) {
|
|
190
|
+
push({
|
|
191
|
+
file: file.path,
|
|
192
|
+
line: lineOfIndex(file.masked, m.index),
|
|
193
|
+
message: 'Potential command injection: shell command built with string concatenation',
|
|
194
|
+
evidence: extractContext(file.text, m.index, 60),
|
|
195
|
+
hint: 'Use spawn() with array arguments instead of exec() with strings. Validate and sanitize all user input.',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
{
|
|
204
|
+
id: 'S7',
|
|
205
|
+
level: 'warn',
|
|
206
|
+
title: 'file system operations outside workspace',
|
|
207
|
+
category: 'fs-access',
|
|
208
|
+
check(ctx, options, push) {
|
|
209
|
+
for (const file of ctx.jsFiles) {
|
|
210
|
+
// Look for fs operations with hardcoded absolute paths
|
|
211
|
+
const fsWithAbsPath = /(?:readFile|writeFile|unlink|rmdir|rm)\s*\(\s*['"`](\/[^'"`]+|[A-Z]:\\[^'"`]+)['"`]/g;
|
|
212
|
+
let m;
|
|
213
|
+
|
|
214
|
+
while ((m = fsWithAbsPath.exec(file.masked ?? file.text)) !== null) {
|
|
215
|
+
// Exclude common safe paths
|
|
216
|
+
const path = m[1];
|
|
217
|
+
if (path.startsWith('/tmp') || path.startsWith('/var/tmp')) continue;
|
|
218
|
+
|
|
219
|
+
push({
|
|
220
|
+
file: file.path,
|
|
221
|
+
line: lineOfIndex(file.masked, m.index),
|
|
222
|
+
message: `File system access to hardcoded absolute path: ${path}`,
|
|
223
|
+
evidence: extractContext(file.text, m.index, 60),
|
|
224
|
+
hint: 'Plugins should operate within the workspace. Use ctx.workspace.root or relative paths.',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
id: 'S8',
|
|
233
|
+
level: 'info',
|
|
234
|
+
title: 'uses cryptographic functions',
|
|
235
|
+
category: 'crypto-usage',
|
|
236
|
+
check(ctx, options, push) {
|
|
237
|
+
for (const file of ctx.jsFiles) {
|
|
238
|
+
const cryptoPattern = /\bcrypto\s*\.\s*(createHash|createHmac|createCipher|randomBytes|pbkdf2|scrypt)/g;
|
|
239
|
+
const findings = new Map();
|
|
240
|
+
|
|
241
|
+
let m;
|
|
242
|
+
while ((m = cryptoPattern.exec(file.masked ?? file.text)) !== null) {
|
|
243
|
+
const fn = m[1];
|
|
244
|
+
if (!findings.has(fn)) findings.set(fn, []);
|
|
245
|
+
findings.get(fn).push(lineOfIndex(file.masked, m.index));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const [fn, lines] of findings) {
|
|
249
|
+
push({
|
|
250
|
+
file: file.path,
|
|
251
|
+
line: lines[0],
|
|
252
|
+
message: `Uses crypto.${fn}() (${lines.length} occurrence(s))`,
|
|
253
|
+
evidence: `Lines: ${lines.slice(0, 5).join(', ')}`,
|
|
254
|
+
hint: 'Ensure cryptographic operations use secure parameters (e.g., sufficient iterations for pbkdf2, secure algorithm for createCipher).',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
{
|
|
262
|
+
id: 'S9',
|
|
263
|
+
level: 'warn',
|
|
264
|
+
title: 'accesses process.env without validation',
|
|
265
|
+
category: 'environment-access',
|
|
266
|
+
check(ctx, options, push) {
|
|
267
|
+
for (const file of ctx.jsFiles) {
|
|
268
|
+
// The structural view blanks string interiors: a documented variable name
|
|
269
|
+
// inside a string is not an access.
|
|
270
|
+
const source = file.structural ?? file.masked ?? file.text;
|
|
271
|
+
const envPattern = /process\s*\.\s*env\s*\.\s*([A-Z_][A-Z0-9_]*)/g;
|
|
272
|
+
const accessed = new Set();
|
|
273
|
+
|
|
274
|
+
let m;
|
|
275
|
+
while ((m = envPattern.exec(source)) !== null) {
|
|
276
|
+
accessed.add(m[1]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (accessed.size > 5) {
|
|
280
|
+
push({
|
|
281
|
+
file: file.path,
|
|
282
|
+
line: 1,
|
|
283
|
+
message: `Accesses ${accessed.size} environment variables`,
|
|
284
|
+
evidence: `Variables: ${Array.from(accessed).slice(0, 10).join(', ')}`,
|
|
285
|
+
hint: 'Validate and provide defaults for environment variables. Document required environment variables in README.',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
{
|
|
293
|
+
id: 'S10',
|
|
294
|
+
level: 'warn',
|
|
295
|
+
title: 'uses insecure HTTP in production code',
|
|
296
|
+
category: 'transport-security',
|
|
297
|
+
check(ctx, options, push) {
|
|
298
|
+
// Only a URL used as a request target counts: an XML namespace such as
|
|
299
|
+
// "http://www.w3.org/2000/svg" is a string literal, not a network call.
|
|
300
|
+
const requestPattern = /(?:fetch|axios|https?\s*\.\s*request|new\s+URL)\s*(?:\.\s*\w+\s*)?\(\s*['"`](http:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0)[^'"`]+)['"`]/g;
|
|
301
|
+
for (const file of ctx.jsFiles) {
|
|
302
|
+
const source = file.masked ?? file.text;
|
|
303
|
+
let m;
|
|
304
|
+
|
|
305
|
+
while ((m = requestPattern.exec(source)) !== null) {
|
|
306
|
+
push({
|
|
307
|
+
file: file.path,
|
|
308
|
+
line: lineOfIndex(file.text, m.index),
|
|
309
|
+
message: `Insecure HTTP request target: ${m[1]}`,
|
|
310
|
+
evidence: extractContext(file.text, m.index, 50),
|
|
311
|
+
hint: 'Use HTTPS for network requests to keep data integrity and confidentiality.',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
export const SECURITY_LIMITS = [
|
|
320
|
+
'Experimental static heuristics over source text: a finding describes a pattern, not observed behaviour, and is not a security verdict.',
|
|
321
|
+
'Rules read the masked source: comments are blanked; string literals stay intact where a URL or storage key is itself the evidence.',
|
|
322
|
+
'No rule in this module is error-level, and a clean run does not mean the plugin is safe.',
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Detect potential data exfiltration patterns.
|
|
327
|
+
*/
|
|
328
|
+
function detectDataExfiltration(file, sensitiveDataTypes) {
|
|
329
|
+
const results = [];
|
|
330
|
+
|
|
331
|
+
// Look for fetch/axios calls that reference sensitive data
|
|
332
|
+
const fetchPattern = /(?:fetch|axios\s*\.\s*(?:post|put))\s*\(\s*['"`]([^'"`]+)['"`][^)]*\{[^}]*body[^}]*:/g;
|
|
333
|
+
|
|
334
|
+
let m;
|
|
335
|
+
while ((m = fetchPattern.exec(file.masked)) !== null) {
|
|
336
|
+
const url = m[1];
|
|
337
|
+
const callSite = m.index;
|
|
338
|
+
const contextEnd = Math.min(callSite + 300, file.masked.length);
|
|
339
|
+
const context = file.masked.slice(callSite, contextEnd);
|
|
340
|
+
|
|
341
|
+
// Check if sensitive data types appear in the context
|
|
342
|
+
for (const dataType of sensitiveDataTypes) {
|
|
343
|
+
const dataPattern = new RegExp(`\\b${dataType}\\b`, 'i');
|
|
344
|
+
if (dataPattern.test(context)) {
|
|
345
|
+
// Check if URL is external
|
|
346
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
347
|
+
results.push({
|
|
348
|
+
line: lineOfIndex(file.masked, callSite),
|
|
349
|
+
url,
|
|
350
|
+
dataType,
|
|
351
|
+
snippet: extractContext(file.text, callSite, 100)
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return results;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Extract context around a position for evidence display.
|
|
363
|
+
*/
|
|
364
|
+
function extractContext(text, index, maxLen) {
|
|
365
|
+
const start = Math.max(0, index - Math.floor(maxLen / 2));
|
|
366
|
+
const end = Math.min(text.length, index + Math.ceil(maxLen / 2));
|
|
367
|
+
let snippet = text.slice(start, end);
|
|
368
|
+
|
|
369
|
+
// Clean up for display
|
|
370
|
+
snippet = snippet.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
371
|
+
|
|
372
|
+
if (start > 0) snippet = '...' + snippet;
|
|
373
|
+
if (end < text.length) snippet = snippet + '...';
|
|
374
|
+
|
|
375
|
+
return snippet;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Find line number from character index.
|
|
380
|
+
*/
|
|
381
|
+
function lineOfIndex(text, index) {
|
|
382
|
+
return text.slice(0, index).split('\n').length;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Run security analysis on a loaded package.
|
|
387
|
+
*/
|
|
388
|
+
export function analyzeSecurityDeep(pkg) {
|
|
389
|
+
const findings = [];
|
|
390
|
+
|
|
391
|
+
const ctx = {
|
|
392
|
+
...pkg,
|
|
393
|
+
byPath: (rel) => pkg.jsFiles.find((f) => f.path === rel),
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
for (const rule of SECURITY_RULES) {
|
|
397
|
+
const push = (finding) => {
|
|
398
|
+
findings.push({
|
|
399
|
+
rule: rule.id,
|
|
400
|
+
level: rule.level,
|
|
401
|
+
title: rule.title,
|
|
402
|
+
category: rule.category,
|
|
403
|
+
experimental: true,
|
|
404
|
+
...finding
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
rule.check(ctx, {}, push);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
findings.push({
|
|
412
|
+
rule: rule.id,
|
|
413
|
+
level: 'info',
|
|
414
|
+
title: rule.title,
|
|
415
|
+
category: rule.category,
|
|
416
|
+
experimental: true,
|
|
417
|
+
file: 'package.json',
|
|
418
|
+
line: 1,
|
|
419
|
+
message: 'Security rule did not complete: ' + error.message,
|
|
420
|
+
evidence: '',
|
|
421
|
+
hint: 'This may be a bug in dsh-plugin-lint.',
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Categorize findings
|
|
427
|
+
const byCategory = {};
|
|
428
|
+
for (const finding of findings) {
|
|
429
|
+
const cat = finding.category || 'other';
|
|
430
|
+
if (!byCategory[cat]) byCategory[cat] = [];
|
|
431
|
+
byCategory[cat].push(finding);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const summary = {
|
|
435
|
+
total: findings.length,
|
|
436
|
+
error: findings.filter(f => f.level === 'error').length,
|
|
437
|
+
warn: findings.filter(f => f.level === 'warn').length,
|
|
438
|
+
info: findings.filter(f => f.level === 'info').length,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
schemaVersion: EXPERIMENTAL_SCHEMA_VERSION,
|
|
443
|
+
experimental: true,
|
|
444
|
+
findings,
|
|
445
|
+
byCategory,
|
|
446
|
+
summary,
|
|
447
|
+
riskLevel: assessOverallRisk(summary, byCategory),
|
|
448
|
+
limits: SECURITY_LIMITS,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Assess overall security risk level.
|
|
454
|
+
*/
|
|
455
|
+
function assessOverallRisk(summary, byCategory) {
|
|
456
|
+
// Security findings are heuristic, so none of them is error-level; the risk
|
|
457
|
+
// level is a summary of how many warnings a package carries, not a verdict.
|
|
458
|
+
if (summary.warn >= 10) return 'high';
|
|
459
|
+
if (summary.warn >= 4) return 'medium';
|
|
460
|
+
if (summary.warn >= 1) return 'low';
|
|
461
|
+
return 'minimal';
|
|
462
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { loadPackage } from './core/load.js';
|
|
2
|
+
import { buildPacklist } from './core/packlist.js';
|
|
3
|
+
import { RULES, EVENT_TYPES, localeMeta, DOC } from './core/rules.js';
|
|
4
|
+
import { rulesetIdentity } from './core/baseline.js';
|
|
5
|
+
import { ToolError } from './shared/errors.js';
|
|
6
|
+
|
|
7
|
+
export { RULES, EVENT_TYPES, DOC };
|
|
8
|
+
export { maskSource } from './core/mask.js';
|
|
9
|
+
export { loadPackage, filesCover } from './core/load.js';
|
|
10
|
+
export { formatText, toSarif, toBadge } from './core/report.js';
|
|
11
|
+
|
|
12
|
+
const LEVEL_ORDER = { error: 0, warn: 1, info: 2 };
|
|
13
|
+
|
|
14
|
+
/** Statements every report carries, so a clean run is never read as an endorsement. */
|
|
15
|
+
const LIMITS = [
|
|
16
|
+
'Static analysis of files on disk: it reports what it detected and makes no security judgement.',
|
|
17
|
+
'Line numbers in bundled or minified artifacts point at the bundle, not at the original source.',
|
|
18
|
+
'A rule cites the upstream document it encodes; the installed Harness remains the authority.',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function lintPackage(dir, options = {}) {
|
|
22
|
+
const pkg = loadPackage(dir, options);
|
|
23
|
+
const manifestText = pkg.text('package.json') || '';
|
|
24
|
+
const ctx = Object.assign({}, pkg, {
|
|
25
|
+
manifestText,
|
|
26
|
+
packlist: buildPacklist(dir, pkg.manifest),
|
|
27
|
+
byPath: (rel) => pkg.jsFiles.find((f) => f.path === rel),
|
|
28
|
+
localeFiles: () => (pkg.entries.localeDir ? localeMeta(dir, pkg.entries.localeDir) : []),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const findings = [];
|
|
32
|
+
for (const rule of RULES) {
|
|
33
|
+
const push = (finding) => {
|
|
34
|
+
findings.push(Object.assign({ rule: rule.id, level: rule.level, title: rule.title, doc: rule.doc }, finding));
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
rule.check(ctx, options, push);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const failure = new ToolError('RULE_FAILED', 'lint rule ' + rule.id + ' failed', { cause: error });
|
|
40
|
+
failure.rule = rule.id;
|
|
41
|
+
throw failure;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
findings.sort((a, b) => LEVEL_ORDER[a.level] - LEVEL_ORDER[b.level]
|
|
46
|
+
|| String(a.file).localeCompare(String(b.file))
|
|
47
|
+
|| (a.line || 0) - (b.line || 0)
|
|
48
|
+
|| a.rule.localeCompare(b.rule));
|
|
49
|
+
|
|
50
|
+
const summary = { error: 0, warn: 0, info: 0 };
|
|
51
|
+
for (const f of findings) summary[f.level] += 1;
|
|
52
|
+
|
|
53
|
+
const declared = (pkg.manifest.dsh && pkg.manifest.dsh.compatibility
|
|
54
|
+
&& pkg.manifest.dsh.compatibility.dshReleases) || undefined;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
schemaVersion: 1,
|
|
58
|
+
target: {
|
|
59
|
+
name: pkg.manifest.name,
|
|
60
|
+
version: pkg.manifest.version,
|
|
61
|
+
path: dir,
|
|
62
|
+
...(options.source ? { source: options.source } : {}),
|
|
63
|
+
},
|
|
64
|
+
dsh: {
|
|
65
|
+
declared: declared ? Object.keys(declared) : [],
|
|
66
|
+
verified: [],
|
|
67
|
+
},
|
|
68
|
+
checks: RULES.length,
|
|
69
|
+
ruleset: rulesetIdentity(),
|
|
70
|
+
summary,
|
|
71
|
+
findings,
|
|
72
|
+
limits: LIMITS,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/load.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './core/load.js';
|
package/src/mask.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './core/mask.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { stableStringify, sha256 } from '../shared/hash.js';
|
|
2
|
+
import { ToolError } from '../shared/errors.js';
|
|
3
|
+
import { CELL_SCHEMA_VERSION } from '../shared/version.js';
|
|
4
|
+
|
|
5
|
+
export function cacheKeyFor({ artifact, dshVersion, composition, probeVersion, schemaVersion = CELL_SCHEMA_VERSION }) {
|
|
6
|
+
const integrityOrSourceDigest = artifact?.integrity ?? artifact?.sourceDigest;
|
|
7
|
+
if (!artifact?.name || !artifact?.resolvedVersion || !integrityOrSourceDigest || !dshVersion || !composition || !probeVersion) {
|
|
8
|
+
throw new ToolError('CACHE_INPUT_INVALID', 'A reproducible cache key needs artifact, DSH, composition, probe, and integrity inputs.');
|
|
9
|
+
}
|
|
10
|
+
return sha256(stableStringify({
|
|
11
|
+
name: artifact.name,
|
|
12
|
+
resolvedVersion: artifact.resolvedVersion,
|
|
13
|
+
integrityOrSourceDigest,
|
|
14
|
+
dshVersion,
|
|
15
|
+
composition,
|
|
16
|
+
probeVersion,
|
|
17
|
+
cellSchemaVersion: schemaVersion,
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { CELL_SCHEMA_VERSION, PROBE_VERSION } from '../shared/version.js';
|
|
2
|
+
import { ToolError } from '../shared/errors.js';
|
|
3
|
+
import { cacheKeyFor } from './cache.js';
|
|
4
|
+
import { validateCell } from './schema.js';
|
|
5
|
+
|
|
6
|
+
export function createCell(input) {
|
|
7
|
+
const { artifact, dsh, observation } = input;
|
|
8
|
+
const composition = input.composition || dsh.composition || observation.composition;
|
|
9
|
+
const probeVersion = input.probeVersion || PROBE_VERSION;
|
|
10
|
+
const cacheKey = cacheKeyFor({ artifact, dshVersion: dsh.version, composition, probeVersion });
|
|
11
|
+
const cell = {
|
|
12
|
+
schemaVersion: CELL_SCHEMA_VERSION,
|
|
13
|
+
cacheKey,
|
|
14
|
+
plugin: {
|
|
15
|
+
name: artifact.name,
|
|
16
|
+
requestedVersion: artifact.requestedVersion,
|
|
17
|
+
resolvedVersion: artifact.resolvedVersion,
|
|
18
|
+
loadedVersion: observation.loadedVersion,
|
|
19
|
+
integrity: artifact.integrity,
|
|
20
|
+
local: artifact.local === true,
|
|
21
|
+
sourceDigest: artifact.sourceDigest,
|
|
22
|
+
path: artifact.path,
|
|
23
|
+
tarballUrl: artifact.tarballUrl,
|
|
24
|
+
},
|
|
25
|
+
dsh: { version: dsh.version, source: dsh.source || dsh.kind || 'npm', composition },
|
|
26
|
+
probe: {
|
|
27
|
+
version: probeVersion,
|
|
28
|
+
isolation: input.isolation || observation.isolation || 'unknown',
|
|
29
|
+
host: input.host || observation.host || { os: process.platform + '-' + process.arch, node: process.version },
|
|
30
|
+
},
|
|
31
|
+
run: {
|
|
32
|
+
outcome: observation.outcome,
|
|
33
|
+
at: input.at || new Date().toISOString(),
|
|
34
|
+
durationMs: observation.durationMs,
|
|
35
|
+
trust: input.trust || 'self-reported',
|
|
36
|
+
},
|
|
37
|
+
reproduce: observation.reproduce,
|
|
38
|
+
};
|
|
39
|
+
if (input.verification) cell.run.verification = input.verification;
|
|
40
|
+
if (input.dispute) cell.run.dispute = input.dispute;
|
|
41
|
+
if (observation.outcome === 'error') {
|
|
42
|
+
// Infrastructure errors can arrive with a numeric exec code; the cell
|
|
43
|
+
// schema requires non-empty strings.
|
|
44
|
+
cell.run.error = {
|
|
45
|
+
code: String(observation.error?.code || 'PROBE_FAILED'),
|
|
46
|
+
message: String(observation.error?.message || 'The probe could not complete.'),
|
|
47
|
+
};
|
|
48
|
+
} else {
|
|
49
|
+
cell.result = observation.result;
|
|
50
|
+
cell.evidence = { note: observation.note || '', outputSha256: observation.outputSha256 };
|
|
51
|
+
}
|
|
52
|
+
const validation = validateCell(cell);
|
|
53
|
+
if (!validation.ok) throw new ToolError('CELL_INVALID', validation.errors.join('; '));
|
|
54
|
+
return cell;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function selectCells(cells, { dshVersion, composition } = {}) {
|
|
58
|
+
const selected = cells.filter((cell) => dshVersion === undefined || cell.dsh.version === dshVersion);
|
|
59
|
+
if (composition !== undefined) {
|
|
60
|
+
const exact = selected.filter((cell) => cell.dsh.composition === composition);
|
|
61
|
+
return { kind: exact.length > 0 ? 'cells' : 'untested', cells: exact };
|
|
62
|
+
}
|
|
63
|
+
if (selected.length === 0) return { kind: 'untested', cells: [] };
|
|
64
|
+
const compositions = new Set(selected.map((cell) => cell.dsh.composition));
|
|
65
|
+
return { kind: compositions.size > 1 ? 'mixed' : 'unknown', cells: selected };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function recommendationEligible(cell, { now = Date.now(), maxAgeDays = 30 } = {}) {
|
|
69
|
+
if (cell?.recommendationEligible === false || cell?.migration) return false;
|
|
70
|
+
if (!validateCell(cell).ok) return false;
|
|
71
|
+
if (cell.plugin.local !== false || typeof cell.plugin.integrity !== 'string') return false;
|
|
72
|
+
if (cell.plugin.resolvedVersion !== cell.plugin.loadedVersion) return false;
|
|
73
|
+
if (cell.run.outcome !== 'completed' || cell.run.trust !== 'verified') return false;
|
|
74
|
+
if (!cell.run.verification || cell.run.verification.probeVersion !== cell.probe.version) return false;
|
|
75
|
+
if (cell.result.status !== 'active') return false;
|
|
76
|
+
const at = Date.parse(cell.run.at);
|
|
77
|
+
const age = now - at;
|
|
78
|
+
return Number.isFinite(at) && age >= 0 && age <= maxAgeDays * 24 * 60 * 60 * 1000;
|
|
79
|
+
}
|