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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.37",
3
+ "version": "2.11.38",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-05-24T21:28:43.647Z",
3
+ "timestamp": "2026-05-24T22:20:18.999Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -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
+ };
@@ -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`);
@@ -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.relPath
365
+ file: ctx.relFile
362
366
  });
363
367
  }
364
368