@lateos/npm-scan 0.15.4 → 0.15.6

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.
@@ -0,0 +1,116 @@
1
+ const ACTIVATION_RISK_MATRIX = {
2
+ '*': { base: 'critical', label: 'Wildcard (all files)' },
3
+ 'onStartupFinished': { base: 'high', label: 'Startup finished' },
4
+ 'workspaceContains:**/*': { base: 'high', label: 'Workspace contains wildcard' },
5
+ 'workspaceContains': { base: 'high', label: 'Workspace contains' },
6
+ 'onCommand:*': { base: 'low', label: 'Any command' },
7
+ };
8
+
9
+ const DEFAULT_BASE_RISK = 'medium';
10
+
11
+ const ESCALATION_KEYWORDS = [
12
+ 'npx', 'bun', 'curl', 'wget', 'fetch(',
13
+ 'exec(', 'spawn(', 'execSync', 'spawnSync',
14
+ 'child_process', 'shell: true', 'detached: true',
15
+ ];
16
+
17
+ const BUNDLED_BUN_PATTERN = /bun|runtime/;
18
+
19
+ const SIZE_DELTA_THRESHOLD = 400 * 1024;
20
+
21
+ const SHELL_CMDS = ['npx', 'bun', 'curl', 'wget', 'exec', 'spawn', 'execSync'];
22
+
23
+ export async function checkActivationEventRisk(extensionManifest, versionHistory = [], priorVersions = []) {
24
+ const signals = [];
25
+
26
+ const activationEvents = extensionManifest?.activationEvents || [];
27
+ if (activationEvents.length === 0 && extensionManifest?.main) {
28
+ return { triggered: false, signals: [], riskLevel: null, why: [] };
29
+ }
30
+
31
+ let maxBaseRisk = 0;
32
+ const riskLabels = ['none', 'low', 'medium', 'high', 'critical'];
33
+ const riskValues = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
34
+
35
+ let worstEvent = null;
36
+ const why = [];
37
+
38
+ for (const event of activationEvents) {
39
+ const risk = ACTIVATION_RISK_MATRIX[event];
40
+ if (risk) {
41
+ const baseIdx = riskValues[risk.base] || riskValues[DEFAULT_BASE_RISK];
42
+ if (baseIdx > maxBaseRisk) {
43
+ maxBaseRisk = baseIdx;
44
+ worstEvent = event;
45
+ }
46
+ } else if (event.includes('*') && event !== 'onCommand:*') {
47
+ const baseIdx = riskValues['high'];
48
+ if (baseIdx > maxBaseRisk) {
49
+ maxBaseRisk = baseIdx;
50
+ worstEvent = event;
51
+ }
52
+ }
53
+ }
54
+
55
+ const contributes = extensionManifest?.contributes || {};
56
+ const commands = contributes?.commands || [];
57
+ const cmdTitles = commands.map(c => (c.title || '').toLowerCase()).join(' ');
58
+
59
+ const bundledDeps = extensionManifest?.bundledDependencies || [];
60
+ const bundledStr = Array.isArray(bundledDeps) ? bundledDeps.join(' ') : '';
61
+
62
+ const hasShellKeyword = SHELL_CMDS.some(cmd => cmdTitles.includes(cmd));
63
+ const hasBunBundled = BUNDLED_BUN_PATTERN.test(bundledStr);
64
+
65
+ const activationEventsStr = activationEvents.join(' ');
66
+ const hasShellInActivationContext = ESCALATION_KEYWORDS.some(kw => activationEventsStr.toLowerCase().includes(kw.toLowerCase()));
67
+
68
+ let escalateToCritical = false;
69
+
70
+ if (hasShellKeyword || hasBunBundled || hasShellInActivationContext) {
71
+ escalateToCritical = true;
72
+ why.push('HIGH activation event + shell/execution keywords');
73
+ }
74
+
75
+ if (versionHistory.length >= 2) {
76
+ const sizes = versionHistory
77
+ .filter(v => v.assetSize)
78
+ .map(v => v.assetSize)
79
+ .sort((a, b) => b - a);
80
+
81
+ if (sizes.length >= 2 && (sizes[0] - sizes[sizes.length - 1]) > SIZE_DELTA_THRESHOLD) {
82
+ escalateToCritical = true;
83
+ why.push(`HIGH activation event + version size delta > ${SIZE_DELTA_THRESHOLD} bytes`);
84
+ }
85
+ }
86
+
87
+ const priorActivationEvents = priorVersions
88
+ .filter(v => v.activationEvents)
89
+ .flatMap(v => v.activationEvents);
90
+
91
+ if (priorActivationEvents.length > 0) {
92
+ const newEvents = activationEvents.filter(e => !priorActivationEvents.includes(e));
93
+ if (newEvents.length > 0) {
94
+ why.push(`First-time activation event(s) added: ${newEvents.join(', ')}`);
95
+ if (!escalateToCritical && maxBaseRisk >= riskValues['high']) {
96
+ escalateToCritical = true;
97
+ }
98
+ }
99
+ }
100
+
101
+ let riskLevel = maxBaseRisk > 0 ? riskLabels[maxBaseRisk] : null;
102
+ if (escalateToCritical && riskValues[riskLevel] <= riskValues['high']) {
103
+ riskLevel = 'critical';
104
+ }
105
+
106
+ if (!riskLevel) return { triggered: false, signals: [], riskLevel: null, why: [] };
107
+
108
+ signals.push({
109
+ type: 'ACTIVATION_EVENT_RISK',
110
+ activationEvents,
111
+ riskLevel,
112
+ why,
113
+ });
114
+
115
+ return { triggered: true, signals, riskLevel, why };
116
+ }
@@ -0,0 +1,52 @@
1
+ export async function checkBurstPublish(versionHistory, config = {}) {
2
+ const windowMinutes = config.burstWindowMinutes ?? 30;
3
+ const threshold = config.burstVersionThreshold ?? 2;
4
+ const hotPullMinutes = config.hotPullMinutes ?? 20;
5
+
6
+ const entries = versionHistory
7
+ .filter(v => v.publishedAt)
8
+ .map(v => ({ version: v.version, time: new Date(v.publishedAt).getTime() }))
9
+ .filter(e => !Number.isNaN(e.time))
10
+ .sort((a, b) => a.time - b.time);
11
+
12
+ if (entries.length < threshold) return { triggered: false };
13
+
14
+ const windowMs = windowMinutes * 60 * 1000;
15
+ let burstFound = false;
16
+ let burstWindowStart = null;
17
+ let burstWindowEnd = null;
18
+ let burstVersionCount = 0;
19
+ let burstVersions = [];
20
+
21
+ for (let i = 0; i < entries.length; i++) {
22
+ const start = entries[i].time;
23
+ const end = start + windowMs;
24
+ const inWindow = entries.filter(e => e.time >= start && e.time <= end);
25
+
26
+ if (inWindow.length >= threshold) {
27
+ burstFound = true;
28
+ burstWindowStart = new Date(start).toISOString();
29
+ burstWindowEnd = new Date(end).toISOString();
30
+ burstVersionCount = inWindow.length;
31
+ burstVersions = inWindow.map(e => e.version);
32
+ break;
33
+ }
34
+ }
35
+
36
+ let hotPullDetected = false;
37
+ for (let i = 1; i < entries.length; i++) {
38
+ const gapMinutes = (entries[i].time - entries[i - 1].time) / (1000 * 60);
39
+ if (gapMinutes > 0 && gapMinutes < hotPullMinutes) {
40
+ hotPullDetected = true;
41
+ break;
42
+ }
43
+ }
44
+
45
+ return {
46
+ triggered: burstFound || hotPullDetected,
47
+ burstWindow: burstFound
48
+ ? { start: burstWindowStart, end: burstWindowEnd, versionCount: burstVersionCount, versions: burstVersions }
49
+ : null,
50
+ hotPullDetected,
51
+ };
52
+ }
@@ -0,0 +1,88 @@
1
+ const CREDENTIAL_FILE_PATTERNS = [
2
+ /~\/\.npmrc/,
3
+ /~\/\.gitconfig/,
4
+ /~\/\.aws\/credentials/,
5
+ /~\/\.ssh\/id_\w+/,
6
+ /~\/\.vault-token/,
7
+ /~\/\.claude\/settings\.json/,
8
+ /~\/Library\/Application\s+Support\/1Password\//,
9
+ /\/etc\/vault\/token/,
10
+ /\/proc\/\*\/mem/,
11
+ /\$GITHUB_ENV/,
12
+ /\$GITHUB_TOKEN/,
13
+ /\$NPM_TOKEN/,
14
+ /\$NODE_AUTH_TOKEN/,
15
+ /GH_TOKEN/,
16
+ ];
17
+
18
+ const EXFIL_CHANNEL_PATTERNS = [
19
+ /(?:[a-z0-9_-]{40,})\.[a-z0-9_-]+\.(?:com|io|org|net|app|dev|xyz)(?:\/[^\s"')\]]{0,50})?/i,
20
+ /\/gists\b.*authorization/i,
21
+ /\/repos\/[^/]+\/[^/]+\/git\/refs/i,
22
+ /AES-256-GCM/,
23
+ /RSA\/(?:PKCS|OAEP)/,
24
+ ];
25
+
26
+ const ANTI_ANALYSIS_PATTERNS = [
27
+ { pattern: /os\.cpus\(\)\.length\s*<\s*4/, label: 'CPU core count check (< 4)' },
28
+ { pattern: /Intl\.DateTimeFormat.*(?:timeZone|locale)/, label: 'Timezone/locale check' },
29
+ { pattern: /Intl\.DateTimeFormat.*\b(?:ru|rus|kz|by|cn|cns)\b/i, label: 'CIS/locale filtering' },
30
+ { pattern: /\bspawn\(\s*[^,]+,\s*\{[^}]*detached:\s*true\s*\}/, label: 'Detached process spawn' },
31
+ { pattern: /\bBUN_INSTALL\b/, label: 'BUN_INSTALL env reference' },
32
+ { pattern: /~\/\.bun\/bin\/bun/, label: 'Bun binary path' },
33
+ { pattern: /\bBun\.file\(/, label: 'Bun.file() API' },
34
+ { pattern: /\bBun\.serve\(/, label: 'Bun.serve() API' },
35
+ ];
36
+
37
+ function truncateSnippet(str, maxLen = 200) {
38
+ if (!str || str.length <= maxLen) return str || '';
39
+ return str.slice(0, maxLen) + '...';
40
+ }
41
+
42
+ export async function checkExfilPattern(extensionFiles = []) {
43
+ const signals = [];
44
+ const exfilPatterns = [];
45
+ const antiAnalysisTechniques = [];
46
+
47
+ for (const file of extensionFiles) {
48
+ const content = typeof file.content === 'string' ? file.content : '';
49
+ if (!content) continue;
50
+ const path = file.path || '';
51
+
52
+ for (const cp of CREDENTIAL_FILE_PATTERNS) {
53
+ const match = content.match(cp);
54
+ if (match) {
55
+ const snippet = truncateSnippet(match[0]);
56
+ if (!exfilPatterns.some(e => e.includes(snippet))) {
57
+ exfilPatterns.push(`${path}: ${snippet}`);
58
+ signals.push({ type: 'CREDENTIAL_FILE_TARGET', pattern: cp.source, file: path });
59
+ }
60
+ }
61
+ }
62
+
63
+ for (const ep of EXFIL_CHANNEL_PATTERNS) {
64
+ const match = content.match(ep);
65
+ if (match) {
66
+ const snippet = truncateSnippet(match[0]);
67
+ exfilPatterns.push(`${path}: ${snippet}`);
68
+ signals.push({ type: 'EXFIL_CHANNEL', pattern: ep.source, file: path });
69
+ }
70
+ }
71
+
72
+ for (const ap of ANTI_ANALYSIS_PATTERNS) {
73
+ if (ap.pattern.test(content)) {
74
+ if (!antiAnalysisTechniques.includes(ap.label)) {
75
+ antiAnalysisTechniques.push(ap.label);
76
+ signals.push({ type: 'ANTI_ANALYSIS', technique: ap.label, file: path });
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ return {
83
+ triggered: signals.length > 0,
84
+ signals,
85
+ exfilPatterns,
86
+ antiAnalysisTechniques,
87
+ };
88
+ }
@@ -0,0 +1,105 @@
1
+ import { readFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+
5
+ let iocsData = null;
6
+ let iocsLoaded = false;
7
+ let iocLoadError = null;
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const IOC_PATH = join(__dirname, '..', 'vsix-iocs.json');
12
+
13
+ function loadIOCData() {
14
+ if (iocsLoaded) return iocsData;
15
+ iocsLoaded = true;
16
+ try {
17
+ iocsData = JSON.parse(readFileSync(IOC_PATH, 'utf8'));
18
+ } catch (err) {
19
+ iocLoadError = err;
20
+ iocsData = null;
21
+ }
22
+ return iocsData;
23
+ }
24
+
25
+ export function getIOCLoadError() {
26
+ return iocLoadError;
27
+ }
28
+
29
+ export function reloadIOCData() {
30
+ iocsLoaded = false;
31
+ iocLoadError = null;
32
+ return loadIOCData();
33
+ }
34
+
35
+ export async function checkKnownIOC(extensionId, version, publisherAccount, orphanCommits = [], versionHistory = []) {
36
+ const data = loadIOCData();
37
+ if (!data) return { triggered: false, matches: [] };
38
+
39
+ const matches = [];
40
+ const iocs = data.iocs || [];
41
+
42
+ for (const ioc of iocs) {
43
+ switch (ioc.type) {
44
+ case 'extensionId': {
45
+ if (ioc.value === extensionId) {
46
+ if (!ioc.maliciousVersions || ioc.maliciousVersions.length === 0 || ioc.maliciousVersions.includes(version)) {
47
+ matches.push({
48
+ type: 'extensionId',
49
+ value: extensionId,
50
+ maliciousVersion: version,
51
+ wave: ioc.wave,
52
+ cve: ioc.cve,
53
+ exposureWindowStart: ioc.exposureWindowStart,
54
+ exposureWindowEnd: ioc.exposureWindowEnd,
55
+ });
56
+ }
57
+ }
58
+ break;
59
+ }
60
+
61
+ case 'publisherAccount': {
62
+ if (ioc.value === publisherAccount) {
63
+ const pubTime = versionHistory.length > 0
64
+ ? new Date(versionHistory[versionHistory.length - 1]?.publishedAt).getTime()
65
+ : null;
66
+
67
+ const windowStart = new Date(ioc.compromiseWindowStart).getTime();
68
+ const windowEnd = ioc.compromiseWindowEnd
69
+ ? new Date(ioc.compromiseWindowEnd).getTime()
70
+ : Infinity;
71
+
72
+ if (pubTime && !Number.isNaN(pubTime) && pubTime >= windowStart && pubTime <= windowEnd) {
73
+ matches.push({
74
+ type: 'publisherAccount',
75
+ value: publisherAccount,
76
+ wave: ioc.wave,
77
+ compromiseWindowStart: ioc.compromiseWindowStart,
78
+ compromiseWindowEnd: ioc.compromiseWindowEnd,
79
+ });
80
+ }
81
+ }
82
+ break;
83
+ }
84
+
85
+ case 'orphanCommitHash': {
86
+ for (const commit of orphanCommits) {
87
+ if (ioc.value === commit || (ioc.value === 'PLACEHOLDER_UPDATE_FROM_THREAT_INTEL')) {
88
+ continue;
89
+ }
90
+ if (ioc.value && commit && ioc.value.toLowerCase() === commit.toLowerCase()) {
91
+ matches.push({
92
+ type: 'orphanCommitHash',
93
+ value: commit,
94
+ repo: ioc.repo,
95
+ wave: ioc.wave,
96
+ });
97
+ }
98
+ }
99
+ break;
100
+ }
101
+ }
102
+ }
103
+
104
+ return { triggered: matches.length > 0, matches };
105
+ }
@@ -0,0 +1,69 @@
1
+ const GITHUB_COMMIT_SHA_PATTERN = /api\.github\.com\/repos\/[^/]+\/[^/]+\/git\/commits\/[a-f0-9]{40}/;
2
+ const NPX_GIT_URL_PATTERN = /npx\s+.*github\.com.*#[a-f0-9]{8,}/;
3
+ const MCP_KEYWORDS = ['mcp', 'model-context-protocol', 'claude', 'setup', 'init'];
4
+ const EXTERNAL_FETCH_PATTERN = /(?:https?:\/\/)[^\s"')\]]+(?:\.com|\.io|\.org|\.dev|\.app|\.net)[^\s"')\]]*/;
5
+ const NON_NPMJS_FETCH = /(?:fetch|curl|wget)\s*\(?\s*["']https?:\/\/(?!(?:.*npmjs\.org|.*npm\.js\.org|.*github\.com))[^"']+/;
6
+ const BUN_PATTERNS = [/bun\s+install/, /install\s+.*bun/, /\bbunx\b/, /\.bun\/bin\//];
7
+ const NPX_GIT_SHORT = /npx\s+.*github\.com.*#[a-f0-9]{8,}/;
8
+
9
+ export async function checkOrphanCommitFetch(extensionFiles = []) {
10
+ const signals = [];
11
+ const indicators = [];
12
+
13
+ for (const file of extensionFiles) {
14
+ const content = typeof file.content === 'string' ? file.content : '';
15
+ if (!content) continue;
16
+ const path = file.path || '';
17
+
18
+ if (GITHUB_COMMIT_SHA_PATTERN.test(content)) {
19
+ const matches = content.match(GITHUB_COMMIT_SHA_PATTERN);
20
+ if (matches) {
21
+ indicators.push(`${path}: GitHub git commit SHA reference`);
22
+ signals.push({
23
+ type: 'ORPHAN_COMMIT_GITHUB_API',
24
+ indicator: 'GitHub API direct commit SHA resolution',
25
+ file: path,
26
+ });
27
+ }
28
+ }
29
+
30
+ if (NPX_GIT_URL_PATTERN.test(content)) {
31
+ const matches = content.match(NPX_GIT_URL_PATTERN);
32
+ if (matches) {
33
+ indicators.push(`${path}: npx with git URL`);
34
+ signals.push({
35
+ type: 'NPX_GIT_URL',
36
+ indicator: 'npx resolves from git URL (non-registry)',
37
+ file: path,
38
+ });
39
+ }
40
+ }
41
+
42
+ const hasMCPKeywords = MCP_KEYWORDS.some(kw =>
43
+ new RegExp(`\\b${kw}\\b`, 'i').test(content));
44
+ const hasExternalFetch = NON_NPMJS_FETCH.test(content);
45
+
46
+ if (hasMCPKeywords && hasExternalFetch) {
47
+ indicators.push(`${path}: MCP-adjacent keywords + external fetch`);
48
+ signals.push({
49
+ type: 'MCP_DISGUISED_EXFIL',
50
+ indicator: 'Shell command disguised as MCP setup',
51
+ file: path,
52
+ });
53
+ }
54
+
55
+ for (const bp of BUN_PATTERNS) {
56
+ if (bp.test(content)) {
57
+ indicators.push(`${path}: Bun installation pattern`);
58
+ signals.push({
59
+ type: 'BUN_INSTALL',
60
+ indicator: `Bun runtime install pattern: ${bp.source}`,
61
+ file: path,
62
+ });
63
+ break;
64
+ }
65
+ }
66
+ }
67
+
68
+ return { triggered: signals.length > 0, signals, indicators };
69
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }