muaddib-scanner 2.11.37 → 2.11.38
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/bin/muaddib.js
CHANGED
|
@@ -44,6 +44,7 @@ let target = '.';
|
|
|
44
44
|
let jsonOutput = false;
|
|
45
45
|
let htmlOutput = null;
|
|
46
46
|
let sarifOutput = null;
|
|
47
|
+
let cyclonedxOutput = null;
|
|
47
48
|
let explainMode = false;
|
|
48
49
|
let failLevel = 'high';
|
|
49
50
|
let webhookUrl = null;
|
|
@@ -88,6 +89,16 @@ for (let i = 0; i < options.length; i++) {
|
|
|
88
89
|
}
|
|
89
90
|
sarifOutput = sarifPath;
|
|
90
91
|
i++;
|
|
92
|
+
} else if (options[i] === '--cyclonedx') {
|
|
93
|
+
// P1b: CycloneDX 1.5 SBOM export (https://cyclonedx.org)
|
|
94
|
+
const bomPath = options[i + 1] || 'muaddib-bom.cdx.json';
|
|
95
|
+
// CLI-001: Block path traversal
|
|
96
|
+
if (bomPath.includes('..')) {
|
|
97
|
+
console.error('[ERROR] --cyclonedx path must not contain path traversal (..)');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
cyclonedxOutput = bomPath;
|
|
101
|
+
i++;
|
|
91
102
|
} else if (options[i] === '--explain') {
|
|
92
103
|
explainMode = true;
|
|
93
104
|
} else if (options[i] === '--fail-on') {
|
|
@@ -273,6 +284,7 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
273
284
|
json: jsonOutput,
|
|
274
285
|
html: htmlOutput,
|
|
275
286
|
sarif: sarifOutput,
|
|
287
|
+
cyclonedx: cyclonedxOutput,
|
|
276
288
|
explain: explainMode,
|
|
277
289
|
failLevel: failLevel,
|
|
278
290
|
webhook: webhookUrl,
|
package/package.json
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// P1b — CycloneDX 1.5 SBOM export.
|
|
2
|
+
//
|
|
3
|
+
// Maps muaddib scan results to CycloneDX vulnerabilities affecting the scanned
|
|
4
|
+
// package (root component). Consumed by SBOM-oriented pipelines: Dependency-
|
|
5
|
+
// Track, Anchore, Snyk, Trivy, GitHub Security, etc.
|
|
6
|
+
//
|
|
7
|
+
// Spec : https://cyclonedx.org/docs/1.5/json/
|
|
8
|
+
// Why 1.5 and not 1.6 : v1.5 has universal consumer support; v1.6 adds
|
|
9
|
+
// features (mldata, evidence) we don't use.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const { getRuleDomain } = require('../rules/index.js');
|
|
15
|
+
|
|
16
|
+
const SPEC_VERSION = '1.5';
|
|
17
|
+
const TOOL_NAME = 'muaddib';
|
|
18
|
+
const TOOL_VENDOR = 'muaddib';
|
|
19
|
+
const TOOL_URL = 'https://github.com/DNSZLSK/muad-dib';
|
|
20
|
+
const ROOT_BOM_REF = 'scanned-package';
|
|
21
|
+
|
|
22
|
+
const _muaddibVersion = (() => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')).version;
|
|
25
|
+
} catch {
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map muaddib severity (uppercase) → CycloneDX severity (lowercase).
|
|
32
|
+
* Unknown values fall to "info" (CycloneDX's mildest level).
|
|
33
|
+
*/
|
|
34
|
+
function severityToCycloneDX(severity) {
|
|
35
|
+
switch (severity) {
|
|
36
|
+
case 'CRITICAL': return 'critical';
|
|
37
|
+
case 'HIGH': return 'high';
|
|
38
|
+
case 'MEDIUM': return 'medium';
|
|
39
|
+
case 'LOW': return 'low';
|
|
40
|
+
default: return 'info';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a package URL (purl) for the scanned component.
|
|
46
|
+
* https://github.com/package-url/purl-spec
|
|
47
|
+
* - npm scoped: pkg:npm/%40scope%2Fname@version
|
|
48
|
+
* - npm unscoped: pkg:npm/name@version
|
|
49
|
+
* - generic fall: pkg:generic/dirname@0.0.0
|
|
50
|
+
*
|
|
51
|
+
* When `hasPackageJson` is true we assume npm. PyPI / other ecosystems would
|
|
52
|
+
* need a separate detector (out of scope for v1).
|
|
53
|
+
*/
|
|
54
|
+
function buildPurl(name, version, hasPackageJson) {
|
|
55
|
+
const safeVersion = version || '0.0.0';
|
|
56
|
+
if (!hasPackageJson) {
|
|
57
|
+
return 'pkg:generic/' + encodeURIComponent(name || 'unknown') + '@' + encodeURIComponent(safeVersion);
|
|
58
|
+
}
|
|
59
|
+
if (name && name.startsWith('@') && name.includes('/')) {
|
|
60
|
+
const slashIdx = name.indexOf('/');
|
|
61
|
+
const scope = name.slice(0, slashIdx);
|
|
62
|
+
const rest = name.slice(slashIdx + 1);
|
|
63
|
+
return 'pkg:npm/' + encodeURIComponent(scope) + '/' + encodeURIComponent(rest) + '@' + encodeURIComponent(safeVersion);
|
|
64
|
+
}
|
|
65
|
+
return 'pkg:npm/' + encodeURIComponent(name || 'unknown') + '@' + encodeURIComponent(safeVersion);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the scanned target's package.json (if present) to derive root identity.
|
|
70
|
+
* Returns { name, version, hasPackageJson }.
|
|
71
|
+
*/
|
|
72
|
+
function resolveRootComponent(targetPath) {
|
|
73
|
+
let name = null;
|
|
74
|
+
let version = null;
|
|
75
|
+
let hasPackageJson = false;
|
|
76
|
+
if (targetPath && typeof targetPath === 'string') {
|
|
77
|
+
try {
|
|
78
|
+
const pkgPath = path.join(targetPath, 'package.json');
|
|
79
|
+
if (fs.existsSync(pkgPath)) {
|
|
80
|
+
const data = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
81
|
+
if (typeof data.name === 'string' && data.name.length > 0) name = data.name;
|
|
82
|
+
if (typeof data.version === 'string' && data.version.length > 0) version = data.version;
|
|
83
|
+
hasPackageJson = true;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore — fallback to dirname below
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!name) {
|
|
90
|
+
// Last-resort fallback: dirname of the target
|
|
91
|
+
try {
|
|
92
|
+
name = targetPath ? path.basename(path.resolve(targetPath)) : 'unknown';
|
|
93
|
+
} catch {
|
|
94
|
+
name = 'unknown';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!version) version = '0.0.0';
|
|
98
|
+
return { name, version, hasPackageJson };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the properties array for a vulnerability — exposes muaddib-specific
|
|
103
|
+
* data (risk_domain, confidence, mitre, type, file, line) using a namespaced
|
|
104
|
+
* key convention so consumers can filter/group on them without collisions.
|
|
105
|
+
*/
|
|
106
|
+
function vulnerabilityProperties(threat) {
|
|
107
|
+
const props = [];
|
|
108
|
+
const domain = threat.domain || getRuleDomain(threat.type);
|
|
109
|
+
if (domain) props.push({ name: 'muaddib:risk_domain', value: String(domain) });
|
|
110
|
+
if (threat.type) props.push({ name: 'muaddib:type', value: String(threat.type) });
|
|
111
|
+
if (threat.confidence) props.push({ name: 'muaddib:confidence', value: String(threat.confidence) });
|
|
112
|
+
if (threat.mitre) props.push({ name: 'muaddib:mitre', value: String(threat.mitre) });
|
|
113
|
+
if (threat.file) props.push({ name: 'muaddib:file', value: String(threat.file) });
|
|
114
|
+
if (threat.line) props.push({ name: 'muaddib:line', value: String(threat.line) });
|
|
115
|
+
if (typeof threat.points === 'number') props.push({ name: 'muaddib:points', value: String(threat.points) });
|
|
116
|
+
return props;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function vulnerabilityFromThreat(threat, idx) {
|
|
120
|
+
const sev = severityToCycloneDX(threat.severity);
|
|
121
|
+
const score = (typeof threat.points === 'number') ? threat.points : 0;
|
|
122
|
+
return {
|
|
123
|
+
'bom-ref': 'muaddib-vuln-' + idx,
|
|
124
|
+
id: threat.rule_id || threat.type || ('MUADDIB-UNK-' + idx),
|
|
125
|
+
source: { name: 'MUADDIB', url: TOOL_URL },
|
|
126
|
+
description: threat.message || (threat.type || 'muaddib threat'),
|
|
127
|
+
ratings: [
|
|
128
|
+
{
|
|
129
|
+
source: { name: 'MUADDIB' },
|
|
130
|
+
severity: sev,
|
|
131
|
+
method: 'other',
|
|
132
|
+
score,
|
|
133
|
+
vector: 'muaddib-confidence:' + (threat.confidence || 'medium')
|
|
134
|
+
}
|
|
135
|
+
],
|
|
136
|
+
affects: [{ ref: ROOT_BOM_REF }],
|
|
137
|
+
properties: vulnerabilityProperties(threat)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Generate a CycloneDX 1.5 BOM document from a muaddib scan result.
|
|
143
|
+
* @param {object} results - scan result object (must contain `threats`, `target`, optionally `timestamp`)
|
|
144
|
+
* @returns {object} CycloneDX BOM ready to JSON.stringify
|
|
145
|
+
*/
|
|
146
|
+
function generateCycloneDX(results) {
|
|
147
|
+
const target = (results && results.target) || '.';
|
|
148
|
+
const timestamp = (results && results.timestamp) || new Date().toISOString();
|
|
149
|
+
const root = resolveRootComponent(target);
|
|
150
|
+
const purl = buildPurl(root.name, root.version, root.hasPackageJson);
|
|
151
|
+
|
|
152
|
+
const threats = Array.isArray(results && results.threats) ? results.threats : [];
|
|
153
|
+
const vulnerabilities = threats.map((t, i) => vulnerabilityFromThreat(t, i + 1));
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
bomFormat: 'CycloneDX',
|
|
157
|
+
specVersion: SPEC_VERSION,
|
|
158
|
+
serialNumber: 'urn:uuid:' + crypto.randomUUID(),
|
|
159
|
+
version: 1,
|
|
160
|
+
metadata: {
|
|
161
|
+
timestamp,
|
|
162
|
+
tools: [
|
|
163
|
+
{
|
|
164
|
+
vendor: TOOL_VENDOR,
|
|
165
|
+
name: TOOL_NAME,
|
|
166
|
+
version: _muaddibVersion
|
|
167
|
+
}
|
|
168
|
+
],
|
|
169
|
+
component: {
|
|
170
|
+
'bom-ref': ROOT_BOM_REF,
|
|
171
|
+
type: 'library',
|
|
172
|
+
name: root.name,
|
|
173
|
+
version: root.version,
|
|
174
|
+
purl
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
vulnerabilities
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Write the generated BOM to disk. Mirrors saveSARIF semantics.
|
|
183
|
+
*/
|
|
184
|
+
function saveCycloneDX(results, outputPath) {
|
|
185
|
+
if (!outputPath || typeof outputPath !== 'string') {
|
|
186
|
+
throw new Error('Invalid output path for CycloneDX report');
|
|
187
|
+
}
|
|
188
|
+
const bom = generateCycloneDX(results);
|
|
189
|
+
try {
|
|
190
|
+
fs.writeFileSync(outputPath, JSON.stringify(bom, null, 2));
|
|
191
|
+
} catch (e) {
|
|
192
|
+
throw new Error('Failed to write CycloneDX report to ' + outputPath + ': ' + e.message);
|
|
193
|
+
}
|
|
194
|
+
return outputPath;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
generateCycloneDX,
|
|
199
|
+
saveCycloneDX,
|
|
200
|
+
severityToCycloneDX,
|
|
201
|
+
buildPurl,
|
|
202
|
+
resolveRootComponent,
|
|
203
|
+
SPEC_VERSION
|
|
204
|
+
};
|
package/src/output/formatter.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { saveReport } = require('../report.js');
|
|
2
2
|
const { saveSARIF } = require('../sarif.js');
|
|
3
|
+
const { saveCycloneDX } = require('./cyclonedx.js');
|
|
3
4
|
const { getPlaybook } = require('../response/playbooks.js');
|
|
4
5
|
const { DOMAIN_CODES, getRuleDomain } = require('../rules/index.js');
|
|
5
6
|
|
|
@@ -52,6 +53,11 @@ function formatOutput(result, options, ctx) {
|
|
|
52
53
|
saveSARIF(result, options.sarif);
|
|
53
54
|
console.log(`[OK] SARIF report generated: ${options.sarif}`);
|
|
54
55
|
}
|
|
56
|
+
// P1b — CycloneDX 1.5 SBOM output
|
|
57
|
+
else if (options.cyclonedx) {
|
|
58
|
+
saveCycloneDX(result, options.cyclonedx);
|
|
59
|
+
console.log(`[OK] CycloneDX BOM generated: ${options.cyclonedx}`);
|
|
60
|
+
}
|
|
55
61
|
// Explain output
|
|
56
62
|
else if (options.explain) {
|
|
57
63
|
if (!spinner) console.log(`\n[MUADDIB] Scanning ${targetPath}\n`);
|
package/src/scanner/ast.js
CHANGED
|
@@ -37,7 +37,11 @@ const EXCLUDED_FILES = [
|
|
|
37
37
|
'src/scanner/ast.js',
|
|
38
38
|
'src/scanner/shell.js',
|
|
39
39
|
'src/scanner/package.js',
|
|
40
|
-
'src/response/playbooks.js'
|
|
40
|
+
'src/response/playbooks.js',
|
|
41
|
+
// Meta-rule descriptions contain quoted threat keywords (e.g. "ru", LC_ALL,
|
|
42
|
+
// LANG, process.exit in the AST-091 geo_evasion rule). Scanning the rule
|
|
43
|
+
// catalog itself yields self-detections — exclude it.
|
|
44
|
+
'src/rules/index.js'
|
|
41
45
|
];
|
|
42
46
|
|
|
43
47
|
async function analyzeAST(targetPath, options = {}) {
|
|
@@ -358,7 +362,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
358
362
|
type: 'geo_evasion_killswitch',
|
|
359
363
|
severity: 'HIGH',
|
|
360
364
|
message: 'Geo-evasion CIS kill switch: locale check for "ru" + process.exit — malware avoids targeting operator\'s country (TeamPCP pattern)',
|
|
361
|
-
file: ctx.
|
|
365
|
+
file: ctx.relFile
|
|
362
366
|
});
|
|
363
367
|
}
|
|
364
368
|
|