@lateos/npm-scan 0.18.2 → 0.18.3
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/.dockerignore +20 -20
- package/.husky/pre-commit +1 -1
- package/CHANGELOG.md +233 -233
- package/LICENSING.md +19 -19
- package/README.de.md +708 -708
- package/README.fr.md +707 -707
- package/README.ja.md +704 -704
- package/README.md +826 -826
- package/README.zh.md +708 -708
- package/SECURITY.md +72 -72
- package/backend/cra.js +68 -68
- package/backend/db/schema.sql +32 -32
- package/backend/db.js +88 -88
- package/backend/detectors/atk-001-lifecycle.js +17 -17
- package/backend/detectors/atk-002-obfusc.js +261 -261
- package/backend/detectors/atk-003-creds.js +13 -13
- package/backend/detectors/atk-004-persist.js +13 -13
- package/backend/detectors/atk-005-exfil.js +13 -13
- package/backend/detectors/atk-006-depconf.js +14 -14
- package/backend/detectors/atk-007-typosquat.js +34 -34
- package/backend/detectors/atk-008-tarball-tamper.js +91 -91
- package/backend/detectors/atk-009-dormant-trigger.js +62 -62
- package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
- package/backend/detectors/atk-011-transitive-prop.js +76 -76
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
- package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
- package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
- package/backend/detectors/hf-impersonation/index.js +396 -396
- package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
- package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
- package/backend/detectors/hf-impersonation/simhash.js +46 -46
- package/backend/detectors/index.js +81 -81
- package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
- package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
- package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
- package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
- package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
- package/backend/detectors/megalodon/index.js +80 -80
- package/backend/detectors/megalodon/types.js +9 -9
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
- package/backend/detectors/mini-shai-hulud/index.js +118 -118
- package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
- package/backend/fetch.js +175 -175
- package/backend/index.js +4 -4
- package/backend/license.js +89 -89
- package/backend/lockfile.js +379 -379
- package/backend/pdf.js +245 -245
- package/backend/policy.js +193 -193
- package/backend/report.js +254 -254
- package/backend/sbom.js +66 -66
- package/backend/siem/cef.js +32 -32
- package/backend/siem/ecs.js +40 -40
- package/backend/siem/index.js +18 -18
- package/backend/siem/qradar.js +56 -56
- package/backend/siem/sentinel.js +27 -27
- package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
- package/backend/vsix-scan/detectors/burst-publish.js +52 -52
- package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
- package/backend/vsix-scan/detectors/known-ioc.js +105 -105
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
- package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
- package/backend/vsix-scan/index.js +183 -183
- package/backend/vsix-scan/marketplace-client.js +145 -145
- package/backend/vsix-scan/vsix-iocs.json +31 -31
- package/cli/cli.js +458 -458
- package/deploy/helm/npm-scan/Chart.yaml +21 -21
- package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
- package/deploy/helm/npm-scan/templates/api.yaml +93 -93
- package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
- package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
- package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
- package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
- package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
- package/deploy/helm/npm-scan/values.yaml +102 -102
- package/package.json +57 -57
- package/scripts/download-corpus.js +30 -30
- package/scripts/gen-mal-corpus.js +34 -34
- package/test/fixtures/lockfiles/npm-lock.json +68 -68
- package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
- package/test/fixtures/lockfiles/yarn.lock +103 -103
- package/test/fixtures/mock-data.js +69 -69
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
export async function checkPublisherAnomaly(extensionMetadata, publisherProfile, versionHistory, config = {}) {
|
|
2
|
-
const signals = [];
|
|
3
|
-
|
|
4
|
-
const crossNamespaceThreshold = config.crossNamespaceThreshold ?? 3;
|
|
5
|
-
const crossNamespaceDays = config.crossNamespaceDays ?? 14;
|
|
6
|
-
const newAccountAgeDays = config.newAccountAgeDays ?? 30;
|
|
7
|
-
const highInstallThreshold = config.highInstallThreshold ?? 100000;
|
|
8
|
-
const addPublishWindowMinutes = config.addPublishWindowMinutes ?? 15;
|
|
9
|
-
|
|
10
|
-
const versions = versionHistory || [];
|
|
11
|
-
if (versions.length === 0) return { triggered: false, signals: [] };
|
|
12
|
-
|
|
13
|
-
const publishers = [...new Set(versions.map(v => v.publishedBy).filter(Boolean))];
|
|
14
|
-
if (publishers.length === 0) return { triggered: false, signals: [] };
|
|
15
|
-
|
|
16
|
-
const sortedVersions = [...versions]
|
|
17
|
-
.filter(v => v.publishedAt)
|
|
18
|
-
.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
|
|
19
|
-
|
|
20
|
-
const extPublisher = publishers[0];
|
|
21
|
-
const allSame = publishers.every(p => p === extPublisher);
|
|
22
|
-
|
|
23
|
-
if (!allSame) {
|
|
24
|
-
for (const pub of publishers) {
|
|
25
|
-
if (pub !== extPublisher) {
|
|
26
|
-
signals.push({
|
|
27
|
-
type: 'PUBLISHER_ACCOUNT_SUBSTITUTION',
|
|
28
|
-
expectedPublisher: extPublisher,
|
|
29
|
-
unexpectedPublisher: pub,
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const extInstallCount = extensionMetadata?.statistics?.find(s => s.statisticName === 'install')?.value || 0;
|
|
36
|
-
|
|
37
|
-
const extAgeDays = publisherProfile?.dateCreated
|
|
38
|
-
? (Date.now() - new Date(publisherProfile.dateCreated).getTime()) / (1000 * 60 * 60 * 24)
|
|
39
|
-
: null;
|
|
40
|
-
|
|
41
|
-
if (extAgeDays !== null && extAgeDays < newAccountAgeDays && extInstallCount >= highInstallThreshold) {
|
|
42
|
-
signals.push({
|
|
43
|
-
type: 'NEW_ACCOUNT_HIGH_INSTALL',
|
|
44
|
-
accountAgeDays: Math.round(extAgeDays),
|
|
45
|
-
installCount: extInstallCount,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (sortedVersions.length >= 2) {
|
|
50
|
-
const sorted = sortedVersions;
|
|
51
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
52
|
-
const prev = sorted[i - 1];
|
|
53
|
-
const curr = sorted[i];
|
|
54
|
-
if (curr.publishedBy !== prev.publishedBy) {
|
|
55
|
-
const gapMinutes = (new Date(curr.publishedAt) - new Date(prev.publishedAt)) / (1000 * 60);
|
|
56
|
-
if (gapMinutes <= addPublishWindowMinutes) {
|
|
57
|
-
signals.push({
|
|
58
|
-
type: 'ADD_PUBLISH_RAPID',
|
|
59
|
-
version: curr.version,
|
|
60
|
-
previousPublisher: prev.publishedBy,
|
|
61
|
-
newPublisher: curr.publishedBy,
|
|
62
|
-
gapMinutes: Math.round(gapMinutes * 100) / 100,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { triggered: signals.length > 0, signals };
|
|
70
|
-
}
|
|
1
|
+
export async function checkPublisherAnomaly(extensionMetadata, publisherProfile, versionHistory, config = {}) {
|
|
2
|
+
const signals = [];
|
|
3
|
+
|
|
4
|
+
const crossNamespaceThreshold = config.crossNamespaceThreshold ?? 3;
|
|
5
|
+
const crossNamespaceDays = config.crossNamespaceDays ?? 14;
|
|
6
|
+
const newAccountAgeDays = config.newAccountAgeDays ?? 30;
|
|
7
|
+
const highInstallThreshold = config.highInstallThreshold ?? 100000;
|
|
8
|
+
const addPublishWindowMinutes = config.addPublishWindowMinutes ?? 15;
|
|
9
|
+
|
|
10
|
+
const versions = versionHistory || [];
|
|
11
|
+
if (versions.length === 0) return { triggered: false, signals: [] };
|
|
12
|
+
|
|
13
|
+
const publishers = [...new Set(versions.map(v => v.publishedBy).filter(Boolean))];
|
|
14
|
+
if (publishers.length === 0) return { triggered: false, signals: [] };
|
|
15
|
+
|
|
16
|
+
const sortedVersions = [...versions]
|
|
17
|
+
.filter(v => v.publishedAt)
|
|
18
|
+
.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
|
|
19
|
+
|
|
20
|
+
const extPublisher = publishers[0];
|
|
21
|
+
const allSame = publishers.every(p => p === extPublisher);
|
|
22
|
+
|
|
23
|
+
if (!allSame) {
|
|
24
|
+
for (const pub of publishers) {
|
|
25
|
+
if (pub !== extPublisher) {
|
|
26
|
+
signals.push({
|
|
27
|
+
type: 'PUBLISHER_ACCOUNT_SUBSTITUTION',
|
|
28
|
+
expectedPublisher: extPublisher,
|
|
29
|
+
unexpectedPublisher: pub,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const extInstallCount = extensionMetadata?.statistics?.find(s => s.statisticName === 'install')?.value || 0;
|
|
36
|
+
|
|
37
|
+
const extAgeDays = publisherProfile?.dateCreated
|
|
38
|
+
? (Date.now() - new Date(publisherProfile.dateCreated).getTime()) / (1000 * 60 * 60 * 24)
|
|
39
|
+
: null;
|
|
40
|
+
|
|
41
|
+
if (extAgeDays !== null && extAgeDays < newAccountAgeDays && extInstallCount >= highInstallThreshold) {
|
|
42
|
+
signals.push({
|
|
43
|
+
type: 'NEW_ACCOUNT_HIGH_INSTALL',
|
|
44
|
+
accountAgeDays: Math.round(extAgeDays),
|
|
45
|
+
installCount: extInstallCount,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (sortedVersions.length >= 2) {
|
|
50
|
+
const sorted = sortedVersions;
|
|
51
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
52
|
+
const prev = sorted[i - 1];
|
|
53
|
+
const curr = sorted[i];
|
|
54
|
+
if (curr.publishedBy !== prev.publishedBy) {
|
|
55
|
+
const gapMinutes = (new Date(curr.publishedAt) - new Date(prev.publishedAt)) / (1000 * 60);
|
|
56
|
+
if (gapMinutes <= addPublishWindowMinutes) {
|
|
57
|
+
signals.push({
|
|
58
|
+
type: 'ADD_PUBLISH_RAPID',
|
|
59
|
+
version: curr.version,
|
|
60
|
+
previousPublisher: prev.publishedBy,
|
|
61
|
+
newPublisher: curr.publishedBy,
|
|
62
|
+
gapMinutes: Math.round(gapMinutes * 100) / 100,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { triggered: signals.length > 0, signals };
|
|
70
|
+
}
|
|
@@ -1,183 +1,183 @@
|
|
|
1
|
-
import { checkBurstPublish } from './detectors/burst-publish.js';
|
|
2
|
-
import { checkPublisherAnomaly } from './detectors/publisher-anomaly.js';
|
|
3
|
-
import { checkActivationEventRisk } from './detectors/activation-event-risk.js';
|
|
4
|
-
import { checkOrphanCommitFetch } from './detectors/orphan-commit-fetch.js';
|
|
5
|
-
import { checkKnownIOC } from './detectors/known-ioc.js';
|
|
6
|
-
import { checkExfilPattern } from './detectors/exfil-pattern.js';
|
|
7
|
-
import { getExtensionMetadata, getVersionHistory, getPublisherProfile, getOpenVsxMetadata, getOpenVsxVersionHistory } from './marketplace-client.js';
|
|
8
|
-
|
|
9
|
-
const SEVERITY_SCORE = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
10
|
-
const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical'];
|
|
11
|
-
|
|
12
|
-
export async function vsixScan(extensionId, options = {}) {
|
|
13
|
-
const { publisherId, extensionName } = parseExtensionId(extensionId);
|
|
14
|
-
|
|
15
|
-
const marketplaceMeta = options.marketplaceMeta || (options.skipNetwork ? null : await getExtensionMetadata(publisherId, extensionName));
|
|
16
|
-
const marketplaceVersions = options.marketplaceVersions || (marketplaceMeta ? await getVersionHistory(publisherId, extensionName) : []);
|
|
17
|
-
const openVsxVersions = options.openVsxVersions || (options.skipNetwork ? [] : await getOpenVsxVersionHistory(publisherId, extensionName));
|
|
18
|
-
const publisherProfile = options.publisherProfile || (options.skipNetwork ? null : await getPublisherProfile(publisherId));
|
|
19
|
-
|
|
20
|
-
const allVersions = mergeVersionHistories(marketplaceVersions, openVsxVersions);
|
|
21
|
-
const manifest = options.manifest || extractManifest(marketplaceMeta, extensionId);
|
|
22
|
-
|
|
23
|
-
const config = options.config || {};
|
|
24
|
-
|
|
25
|
-
const activationResult = await checkActivationEventRisk(
|
|
26
|
-
manifest,
|
|
27
|
-
allVersions,
|
|
28
|
-
options.priorVersions || [],
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
const burstResult = await checkBurstPublish(allVersions, config);
|
|
32
|
-
|
|
33
|
-
const publisherResult = await checkPublisherAnomaly(
|
|
34
|
-
manifest || {},
|
|
35
|
-
publisherProfile || {},
|
|
36
|
-
allVersions,
|
|
37
|
-
config,
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
const orphanResult = await checkOrphanCommitFetch(options.extensionFiles || []);
|
|
41
|
-
|
|
42
|
-
const iocResult = await checkKnownIOC(
|
|
43
|
-
extensionId,
|
|
44
|
-
options.version || (allVersions.length > 0 ? allVersions[allVersions.length - 1].version : 'unknown'),
|
|
45
|
-
publisherId,
|
|
46
|
-
orphanResult.signals
|
|
47
|
-
.filter(s => s.type === 'ORPHAN_COMMIT_GITHUB_API')
|
|
48
|
-
.map(s => s.indicator),
|
|
49
|
-
allVersions,
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
const exfilResult = await checkExfilPattern(options.extensionFiles || []);
|
|
53
|
-
|
|
54
|
-
const triggeredSignals = [];
|
|
55
|
-
if (burstResult.triggered) triggeredSignals.push('VSIX_BURST_PUBLISH');
|
|
56
|
-
if (publisherResult.triggered) triggeredSignals.push('VSIX_PUBLISHER_ANOMALY');
|
|
57
|
-
if (activationResult.triggered) triggeredSignals.push('VSIX_ACTIVATION_EVENT_RISK');
|
|
58
|
-
if (orphanResult.triggered) triggeredSignals.push('VSIX_ORPHAN_COMMIT_FETCH');
|
|
59
|
-
if (iocResult.triggered) triggeredSignals.push('VSIX_KNOWN_IOC');
|
|
60
|
-
if (exfilResult.triggered) triggeredSignals.push('VSIX_EXFIL_PATTERN');
|
|
61
|
-
|
|
62
|
-
if (triggeredSignals.length === 0) return [];
|
|
63
|
-
|
|
64
|
-
const registryLabels = [];
|
|
65
|
-
if (marketplaceVersions.length > 0) registryLabels.push('marketplace');
|
|
66
|
-
if (openVsxVersions.length > 0) registryLabels.push('open-vsx');
|
|
67
|
-
|
|
68
|
-
const maxSeverity = triggeredSignals.reduce((max, s) => {
|
|
69
|
-
if (s === 'VSIX_KNOWN_IOC' || s === 'VSIX_ORPHAN_COMMIT_FETCH') return Math.max(max, 4);
|
|
70
|
-
if (s === 'VSIX_BURST_PUBLISH' || s === 'VSIX_PUBLISHER_ANOMALY' || s === 'VSIX_EXFIL_PATTERN') return Math.max(max, 3);
|
|
71
|
-
if (s === 'VSIX_ACTIVATION_EVENT_RISK') return Math.max(max, 3);
|
|
72
|
-
return max;
|
|
73
|
-
}, 0);
|
|
74
|
-
|
|
75
|
-
const finalSeverity = SEVERITY_LABELS[maxSeverity] || 'high';
|
|
76
|
-
|
|
77
|
-
const latestVersion = allVersions.length > 0 ? allVersions[allVersions.length - 1].version : 'unknown';
|
|
78
|
-
let exposureWindowMinutes = null;
|
|
79
|
-
if (burstResult.hotPullDetected && allVersions.length >= 2) {
|
|
80
|
-
const sorted = [...allVersions].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
|
81
|
-
const gap = (new Date(sorted[0].publishedAt) - new Date(sorted[1].publishedAt)) / (1000 * 60);
|
|
82
|
-
exposureWindowMinutes = Math.round(gap);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const evidence = {
|
|
86
|
-
extensionId,
|
|
87
|
-
maliciousVersion: latestVersion,
|
|
88
|
-
registries: registryLabels,
|
|
89
|
-
exposureWindowMinutes,
|
|
90
|
-
triggeredSignals,
|
|
91
|
-
burstWindow: burstResult.burstWindow,
|
|
92
|
-
hotPullDetected: burstResult.hotPullDetected,
|
|
93
|
-
publisherSignals: publisherResult.triggered ? publisherResult.signals : null,
|
|
94
|
-
activationEvents: manifest?.activationEvents || null,
|
|
95
|
-
activationRisk: activationResult.triggered ? { riskLevel: activationResult.riskLevel, why: activationResult.why } : null,
|
|
96
|
-
orphanCommitIndicators: orphanResult.triggered ? orphanResult.indicators : null,
|
|
97
|
-
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
98
|
-
exfilPatterns: exfilResult.triggered ? exfilResult.exfilPatterns : null,
|
|
99
|
-
antiAnalysisTechniques: exfilResult.triggered ? exfilResult.antiAnalysisTechniques : null,
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const remediationGuidance = buildRemediation(triggeredSignals, extensionId);
|
|
103
|
-
|
|
104
|
-
return [{
|
|
105
|
-
id: 'VSIX_SCAN',
|
|
106
|
-
severity: finalSeverity,
|
|
107
|
-
title: `VS Code extension risk: ${extensionId}`,
|
|
108
|
-
description: `${triggeredSignals.length} signal(s): ${triggeredSignals.join(', ')}`,
|
|
109
|
-
evidence: JSON.stringify(evidence),
|
|
110
|
-
mitigation: remediationGuidance,
|
|
111
|
-
}];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function parseExtensionId(id) {
|
|
115
|
-
const idx = id.indexOf('.');
|
|
116
|
-
if (idx === -1 || idx === 0 || idx === id.length - 1) {
|
|
117
|
-
throw new Error(`Invalid extension ID: ${id}. Expected format: publisher.extension-name`);
|
|
118
|
-
}
|
|
119
|
-
return { publisherId: id.slice(0, idx), extensionName: id.slice(idx + 1) };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function mergeVersionHistories(marketplace, openVsx) {
|
|
123
|
-
const seen = new Set();
|
|
124
|
-
const merged = [];
|
|
125
|
-
|
|
126
|
-
for (const v of marketplace) {
|
|
127
|
-
if (!seen.has(v.version)) {
|
|
128
|
-
seen.add(v.version);
|
|
129
|
-
merged.push({ ...v, registries: ['marketplace'] });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
for (const v of openVsx) {
|
|
134
|
-
if (!seen.has(v.version)) {
|
|
135
|
-
seen.add(v.version);
|
|
136
|
-
merged.push({ ...v, registries: ['open-vsx'] });
|
|
137
|
-
} else {
|
|
138
|
-
const existing = merged.find(m => m.version === v.version);
|
|
139
|
-
if (existing) {
|
|
140
|
-
existing.registries.push('open-vsx');
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return merged.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function extractManifest(marketplaceMeta, extensionId) {
|
|
149
|
-
if (!marketplaceMeta?.results?.[0]?.extensions?.[0]) return {};
|
|
150
|
-
const ext = marketplaceMeta.results[0].extensions[0];
|
|
151
|
-
const manifestStr = ext.galleryApiUrl || ext.manifest;
|
|
152
|
-
if (!manifestStr) return {};
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
if (typeof manifestStr === 'object') return manifestStr;
|
|
156
|
-
return JSON.parse(manifestStr);
|
|
157
|
-
} catch {
|
|
158
|
-
return {};
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function buildRemediation(triggeredSignals, extensionId) {
|
|
163
|
-
const parts = [];
|
|
164
|
-
if (triggeredSignals.includes('VSIX_KNOWN_IOC')) {
|
|
165
|
-
parts.push(`Extension ${extensionId} matches known campaign IOC. Remove immediately.`);
|
|
166
|
-
}
|
|
167
|
-
if (triggeredSignals.includes('VSIX_BURST_PUBLISH')) {
|
|
168
|
-
parts.push('Suspicious publish velocity detected. Verify publisher release history.');
|
|
169
|
-
}
|
|
170
|
-
if (triggeredSignals.includes('VSIX_PUBLISHER_ANOMALY')) {
|
|
171
|
-
parts.push('Publisher account anomaly detected. Verify publisher identity.');
|
|
172
|
-
}
|
|
173
|
-
if (triggeredSignals.includes('VSIX_ACTIVATION_EVENT_RISK')) {
|
|
174
|
-
parts.push('Risky activation events detected. Review extension activation scope.');
|
|
175
|
-
}
|
|
176
|
-
if (triggeredSignals.includes('VSIX_ORPHAN_COMMIT_FETCH')) {
|
|
177
|
-
parts.push('Dangling orphan commit fetch detected — technical signature of Nx Console attack.');
|
|
178
|
-
}
|
|
179
|
-
if (triggeredSignals.includes('VSIX_EXFIL_PATTERN')) {
|
|
180
|
-
parts.push('Credential exfiltration patterns detected. Revoke all tokens.');
|
|
181
|
-
}
|
|
182
|
-
return parts.join(' ');
|
|
183
|
-
}
|
|
1
|
+
import { checkBurstPublish } from './detectors/burst-publish.js';
|
|
2
|
+
import { checkPublisherAnomaly } from './detectors/publisher-anomaly.js';
|
|
3
|
+
import { checkActivationEventRisk } from './detectors/activation-event-risk.js';
|
|
4
|
+
import { checkOrphanCommitFetch } from './detectors/orphan-commit-fetch.js';
|
|
5
|
+
import { checkKnownIOC } from './detectors/known-ioc.js';
|
|
6
|
+
import { checkExfilPattern } from './detectors/exfil-pattern.js';
|
|
7
|
+
import { getExtensionMetadata, getVersionHistory, getPublisherProfile, getOpenVsxMetadata, getOpenVsxVersionHistory } from './marketplace-client.js';
|
|
8
|
+
|
|
9
|
+
const SEVERITY_SCORE = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
10
|
+
const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical'];
|
|
11
|
+
|
|
12
|
+
export async function vsixScan(extensionId, options = {}) {
|
|
13
|
+
const { publisherId, extensionName } = parseExtensionId(extensionId);
|
|
14
|
+
|
|
15
|
+
const marketplaceMeta = options.marketplaceMeta || (options.skipNetwork ? null : await getExtensionMetadata(publisherId, extensionName));
|
|
16
|
+
const marketplaceVersions = options.marketplaceVersions || (marketplaceMeta ? await getVersionHistory(publisherId, extensionName) : []);
|
|
17
|
+
const openVsxVersions = options.openVsxVersions || (options.skipNetwork ? [] : await getOpenVsxVersionHistory(publisherId, extensionName));
|
|
18
|
+
const publisherProfile = options.publisherProfile || (options.skipNetwork ? null : await getPublisherProfile(publisherId));
|
|
19
|
+
|
|
20
|
+
const allVersions = mergeVersionHistories(marketplaceVersions, openVsxVersions);
|
|
21
|
+
const manifest = options.manifest || extractManifest(marketplaceMeta, extensionId);
|
|
22
|
+
|
|
23
|
+
const config = options.config || {};
|
|
24
|
+
|
|
25
|
+
const activationResult = await checkActivationEventRisk(
|
|
26
|
+
manifest,
|
|
27
|
+
allVersions,
|
|
28
|
+
options.priorVersions || [],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const burstResult = await checkBurstPublish(allVersions, config);
|
|
32
|
+
|
|
33
|
+
const publisherResult = await checkPublisherAnomaly(
|
|
34
|
+
manifest || {},
|
|
35
|
+
publisherProfile || {},
|
|
36
|
+
allVersions,
|
|
37
|
+
config,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const orphanResult = await checkOrphanCommitFetch(options.extensionFiles || []);
|
|
41
|
+
|
|
42
|
+
const iocResult = await checkKnownIOC(
|
|
43
|
+
extensionId,
|
|
44
|
+
options.version || (allVersions.length > 0 ? allVersions[allVersions.length - 1].version : 'unknown'),
|
|
45
|
+
publisherId,
|
|
46
|
+
orphanResult.signals
|
|
47
|
+
.filter(s => s.type === 'ORPHAN_COMMIT_GITHUB_API')
|
|
48
|
+
.map(s => s.indicator),
|
|
49
|
+
allVersions,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const exfilResult = await checkExfilPattern(options.extensionFiles || []);
|
|
53
|
+
|
|
54
|
+
const triggeredSignals = [];
|
|
55
|
+
if (burstResult.triggered) triggeredSignals.push('VSIX_BURST_PUBLISH');
|
|
56
|
+
if (publisherResult.triggered) triggeredSignals.push('VSIX_PUBLISHER_ANOMALY');
|
|
57
|
+
if (activationResult.triggered) triggeredSignals.push('VSIX_ACTIVATION_EVENT_RISK');
|
|
58
|
+
if (orphanResult.triggered) triggeredSignals.push('VSIX_ORPHAN_COMMIT_FETCH');
|
|
59
|
+
if (iocResult.triggered) triggeredSignals.push('VSIX_KNOWN_IOC');
|
|
60
|
+
if (exfilResult.triggered) triggeredSignals.push('VSIX_EXFIL_PATTERN');
|
|
61
|
+
|
|
62
|
+
if (triggeredSignals.length === 0) return [];
|
|
63
|
+
|
|
64
|
+
const registryLabels = [];
|
|
65
|
+
if (marketplaceVersions.length > 0) registryLabels.push('marketplace');
|
|
66
|
+
if (openVsxVersions.length > 0) registryLabels.push('open-vsx');
|
|
67
|
+
|
|
68
|
+
const maxSeverity = triggeredSignals.reduce((max, s) => {
|
|
69
|
+
if (s === 'VSIX_KNOWN_IOC' || s === 'VSIX_ORPHAN_COMMIT_FETCH') return Math.max(max, 4);
|
|
70
|
+
if (s === 'VSIX_BURST_PUBLISH' || s === 'VSIX_PUBLISHER_ANOMALY' || s === 'VSIX_EXFIL_PATTERN') return Math.max(max, 3);
|
|
71
|
+
if (s === 'VSIX_ACTIVATION_EVENT_RISK') return Math.max(max, 3);
|
|
72
|
+
return max;
|
|
73
|
+
}, 0);
|
|
74
|
+
|
|
75
|
+
const finalSeverity = SEVERITY_LABELS[maxSeverity] || 'high';
|
|
76
|
+
|
|
77
|
+
const latestVersion = allVersions.length > 0 ? allVersions[allVersions.length - 1].version : 'unknown';
|
|
78
|
+
let exposureWindowMinutes = null;
|
|
79
|
+
if (burstResult.hotPullDetected && allVersions.length >= 2) {
|
|
80
|
+
const sorted = [...allVersions].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
|
81
|
+
const gap = (new Date(sorted[0].publishedAt) - new Date(sorted[1].publishedAt)) / (1000 * 60);
|
|
82
|
+
exposureWindowMinutes = Math.round(gap);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const evidence = {
|
|
86
|
+
extensionId,
|
|
87
|
+
maliciousVersion: latestVersion,
|
|
88
|
+
registries: registryLabels,
|
|
89
|
+
exposureWindowMinutes,
|
|
90
|
+
triggeredSignals,
|
|
91
|
+
burstWindow: burstResult.burstWindow,
|
|
92
|
+
hotPullDetected: burstResult.hotPullDetected,
|
|
93
|
+
publisherSignals: publisherResult.triggered ? publisherResult.signals : null,
|
|
94
|
+
activationEvents: manifest?.activationEvents || null,
|
|
95
|
+
activationRisk: activationResult.triggered ? { riskLevel: activationResult.riskLevel, why: activationResult.why } : null,
|
|
96
|
+
orphanCommitIndicators: orphanResult.triggered ? orphanResult.indicators : null,
|
|
97
|
+
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
98
|
+
exfilPatterns: exfilResult.triggered ? exfilResult.exfilPatterns : null,
|
|
99
|
+
antiAnalysisTechniques: exfilResult.triggered ? exfilResult.antiAnalysisTechniques : null,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const remediationGuidance = buildRemediation(triggeredSignals, extensionId);
|
|
103
|
+
|
|
104
|
+
return [{
|
|
105
|
+
id: 'VSIX_SCAN',
|
|
106
|
+
severity: finalSeverity,
|
|
107
|
+
title: `VS Code extension risk: ${extensionId}`,
|
|
108
|
+
description: `${triggeredSignals.length} signal(s): ${triggeredSignals.join(', ')}`,
|
|
109
|
+
evidence: JSON.stringify(evidence),
|
|
110
|
+
mitigation: remediationGuidance,
|
|
111
|
+
}];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseExtensionId(id) {
|
|
115
|
+
const idx = id.indexOf('.');
|
|
116
|
+
if (idx === -1 || idx === 0 || idx === id.length - 1) {
|
|
117
|
+
throw new Error(`Invalid extension ID: ${id}. Expected format: publisher.extension-name`);
|
|
118
|
+
}
|
|
119
|
+
return { publisherId: id.slice(0, idx), extensionName: id.slice(idx + 1) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mergeVersionHistories(marketplace, openVsx) {
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const merged = [];
|
|
125
|
+
|
|
126
|
+
for (const v of marketplace) {
|
|
127
|
+
if (!seen.has(v.version)) {
|
|
128
|
+
seen.add(v.version);
|
|
129
|
+
merged.push({ ...v, registries: ['marketplace'] });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const v of openVsx) {
|
|
134
|
+
if (!seen.has(v.version)) {
|
|
135
|
+
seen.add(v.version);
|
|
136
|
+
merged.push({ ...v, registries: ['open-vsx'] });
|
|
137
|
+
} else {
|
|
138
|
+
const existing = merged.find(m => m.version === v.version);
|
|
139
|
+
if (existing) {
|
|
140
|
+
existing.registries.push('open-vsx');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return merged.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function extractManifest(marketplaceMeta, extensionId) {
|
|
149
|
+
if (!marketplaceMeta?.results?.[0]?.extensions?.[0]) return {};
|
|
150
|
+
const ext = marketplaceMeta.results[0].extensions[0];
|
|
151
|
+
const manifestStr = ext.galleryApiUrl || ext.manifest;
|
|
152
|
+
if (!manifestStr) return {};
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
if (typeof manifestStr === 'object') return manifestStr;
|
|
156
|
+
return JSON.parse(manifestStr);
|
|
157
|
+
} catch {
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildRemediation(triggeredSignals, extensionId) {
|
|
163
|
+
const parts = [];
|
|
164
|
+
if (triggeredSignals.includes('VSIX_KNOWN_IOC')) {
|
|
165
|
+
parts.push(`Extension ${extensionId} matches known campaign IOC. Remove immediately.`);
|
|
166
|
+
}
|
|
167
|
+
if (triggeredSignals.includes('VSIX_BURST_PUBLISH')) {
|
|
168
|
+
parts.push('Suspicious publish velocity detected. Verify publisher release history.');
|
|
169
|
+
}
|
|
170
|
+
if (triggeredSignals.includes('VSIX_PUBLISHER_ANOMALY')) {
|
|
171
|
+
parts.push('Publisher account anomaly detected. Verify publisher identity.');
|
|
172
|
+
}
|
|
173
|
+
if (triggeredSignals.includes('VSIX_ACTIVATION_EVENT_RISK')) {
|
|
174
|
+
parts.push('Risky activation events detected. Review extension activation scope.');
|
|
175
|
+
}
|
|
176
|
+
if (triggeredSignals.includes('VSIX_ORPHAN_COMMIT_FETCH')) {
|
|
177
|
+
parts.push('Dangling orphan commit fetch detected — technical signature of Nx Console attack.');
|
|
178
|
+
}
|
|
179
|
+
if (triggeredSignals.includes('VSIX_EXFIL_PATTERN')) {
|
|
180
|
+
parts.push('Credential exfiltration patterns detected. Revoke all tokens.');
|
|
181
|
+
}
|
|
182
|
+
return parts.join(' ');
|
|
183
|
+
}
|