@lateos/npm-scan 0.18.3 → 1.1.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/CHANGELOG.md +32 -0
- package/README.md +864 -826
- package/VALIDATION.md +92 -0
- package/backend/cra.js +113 -21
- package/backend/db/pg-schema.sql +155 -0
- package/backend/db.js +18 -10
- package/backend/detectors/atk-001-lifecycle.js +5 -5
- package/backend/detectors/atk-002-obfusc.js +126 -47
- package/backend/detectors/atk-003-creds.js +8 -4
- package/backend/detectors/atk-004-persist.js +3 -3
- package/backend/detectors/atk-005-exfil.js +8 -4
- package/backend/detectors/atk-006-depconf.js +3 -3
- package/backend/detectors/atk-007-typosquat.js +64 -10
- package/backend/detectors/atk-008-tarball-tamper.js +6 -6
- package/backend/detectors/atk-009-dormant-trigger.js +9 -5
- package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
- package/backend/detectors/atk-011-transitive-prop.js +14 -13
- package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
- package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
- package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
- package/backend/detectors/axios-poisoning/index.js +77 -60
- package/backend/detectors/config/thresholds.js +111 -0
- package/backend/detectors/config/whitelist.json +74 -0
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
- package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
- package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
- package/backend/detectors/hf-impersonation/index.js +94 -31
- package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
- package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
- package/backend/detectors/hf-impersonation/simhash.js +2 -2
- package/backend/detectors/index.js +184 -31
- package/backend/detectors/lib/ast-patterns.js +24 -0
- package/backend/detectors/lib/entropy-analyzer.js +32 -0
- package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
- package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
- package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
- package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
- package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
- package/backend/detectors/megalodon/index.js +35 -25
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
- package/backend/detectors/mini-shai-hulud/index.js +63 -26
- package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
- package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
- package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
- package/backend/detectors/msh-supplement/index.js +78 -63
- package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
- package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
- package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
- package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
- package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
- package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
- package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
- package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
- package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
- package/backend/detectors/node-ipc-compromise/index.js +21 -15
- package/backend/detectors/tier1-binary-embed.js +138 -41
- package/backend/detectors/tier1-cloud-imds.js +57 -37
- package/backend/detectors/tier1-encrypted-c2.js +198 -0
- package/backend/detectors/tier1-infostealer.js +121 -68
- package/backend/detectors/tier1-lifecycle-hook.js +63 -23
- package/backend/detectors/tier1-maintainer-compromise.js +157 -0
- package/backend/detectors/tier1-metadata-spoof.js +92 -42
- package/backend/detectors/tier1-multistage-postinstall.js +46 -19
- package/backend/detectors/tier1-obfuscation-heuristics.js +184 -0
- package/backend/detectors/tier1-self-propagation.js +115 -0
- package/backend/detectors/tier1-slsa-attestation.js +12 -0
- package/backend/detectors/tier1-transitive-deps.js +182 -0
- package/backend/detectors/tier1-typosquat.js +129 -50
- package/backend/detectors/tier1-version-anomaly.js +223 -0
- package/backend/detectors/tier1-version-confusion.js +79 -59
- package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
- package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
- package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
- package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
- package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
- package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
- package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
- package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
- package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
- package/backend/detectors/trapdoor/index.js +19 -14
- package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
- package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
- package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
- package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
- package/backend/detectors.test.js +147 -0
- package/backend/fetch.js +37 -29
- package/backend/index.js +1 -1
- package/backend/license.js +20 -4
- package/backend/lockfile.js +60 -36
- package/backend/pdf.js +107 -28
- package/backend/policy.js +183 -56
- package/backend/provenance.js +28 -3
- package/backend/report.js +136 -70
- package/backend/sbom.js +33 -27
- package/backend/scripts/analyze-false-positives.js +152 -0
- package/backend/scripts/analyze-validation.js +157 -0
- package/backend/scripts/detect-false-positives.js +103 -0
- package/backend/scripts/fetch-top-packages.js +277 -0
- package/backend/scripts/validate-d10-d13.js +103 -0
- package/backend/scripts/validate-detectors.js +151 -0
- package/backend/siem/cef.js +23 -21
- package/backend/siem/ecs.js +3 -3
- package/backend/siem/index.js +1 -1
- package/backend/siem/qradar.js +3 -3
- package/backend/siem/sentinel.js +2 -2
- package/backend/tests-d5-enhanced.test.js +47 -0
- package/backend/tests-d6-version-anomaly.test.js +67 -0
- package/backend/tests-d6.test.js +126 -0
- package/backend/tests-d6c.test.js +119 -0
- package/backend/tests-d7-obfuscation.test.js +88 -0
- package/backend/tests.test.js +997 -0
- package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
- package/backend/vsix-scan/detectors/burst-publish.js +14 -7
- package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
- package/backend/vsix-scan/detectors/known-ioc.js +23 -8
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
- package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
- package/backend/vsix-scan/index.js +97 -41
- package/backend/vsix-scan/marketplace-client.js +29 -13
- package/cli/cli.js +154 -64
- package/package.json +36 -10
- package/.dockerignore +0 -20
- package/.husky/pre-commit +0 -1
- package/SECURITY.md +0 -73
- package/deploy/helm/npm-scan/Chart.yaml +0 -22
- package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
- package/deploy/helm/npm-scan/templates/api.yaml +0 -94
- package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
- package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
- package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
- package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
- package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
- package/deploy/helm/npm-scan/values.yaml +0 -103
- package/scripts/download-corpus.js +0 -30
- package/scripts/gen-mal-corpus.js +0 -35
- package/scripts/generate-campaign-fixtures.js +0 -170
- package/src/config/top-5000.json +0 -87
- package/test/fixtures/lockfiles/npm-lock.json +0 -69
- package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
- package/test/fixtures/lockfiles/yarn.lock +0 -104
- package/test/fixtures/mock-data.js +0 -69
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
const ACTIVATION_RISK_MATRIX = {
|
|
2
2
|
'*': { base: 'critical', label: 'Wildcard (all files)' },
|
|
3
|
-
|
|
3
|
+
onStartupFinished: { base: 'high', label: 'Startup finished' },
|
|
4
4
|
'workspaceContains:**/*': { base: 'high', label: 'Workspace contains wildcard' },
|
|
5
|
-
|
|
5
|
+
workspaceContains: { base: 'high', label: 'Workspace contains' },
|
|
6
6
|
'onCommand:*': { base: 'low', label: 'Any command' },
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
const DEFAULT_BASE_RISK = 'medium';
|
|
10
10
|
|
|
11
11
|
const ESCALATION_KEYWORDS = [
|
|
12
|
-
'npx',
|
|
13
|
-
'
|
|
14
|
-
'
|
|
12
|
+
'npx',
|
|
13
|
+
'bun',
|
|
14
|
+
'curl',
|
|
15
|
+
'wget',
|
|
16
|
+
'fetch(',
|
|
17
|
+
'exec(',
|
|
18
|
+
'spawn(',
|
|
19
|
+
'execSync',
|
|
20
|
+
'spawnSync',
|
|
21
|
+
'child_process',
|
|
22
|
+
'shell: true',
|
|
23
|
+
'detached: true',
|
|
15
24
|
];
|
|
16
25
|
|
|
17
26
|
const BUNDLED_BUN_PATTERN = /bun|runtime/;
|
|
@@ -20,7 +29,11 @@ const SIZE_DELTA_THRESHOLD = 400 * 1024;
|
|
|
20
29
|
|
|
21
30
|
const SHELL_CMDS = ['npx', 'bun', 'curl', 'wget', 'exec', 'spawn', 'execSync'];
|
|
22
31
|
|
|
23
|
-
export async function checkActivationEventRisk(
|
|
32
|
+
export async function checkActivationEventRisk(
|
|
33
|
+
extensionManifest,
|
|
34
|
+
versionHistory = [],
|
|
35
|
+
priorVersions = []
|
|
36
|
+
) {
|
|
24
37
|
const signals = [];
|
|
25
38
|
|
|
26
39
|
const activationEvents = extensionManifest?.activationEvents || [];
|
|
@@ -32,7 +45,7 @@ export async function checkActivationEventRisk(extensionManifest, versionHistory
|
|
|
32
45
|
const riskLabels = ['none', 'low', 'medium', 'high', 'critical'];
|
|
33
46
|
const riskValues = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
34
47
|
|
|
35
|
-
let
|
|
48
|
+
let _worstEvent = null;
|
|
36
49
|
const why = [];
|
|
37
50
|
|
|
38
51
|
for (const event of activationEvents) {
|
|
@@ -41,29 +54,31 @@ export async function checkActivationEventRisk(extensionManifest, versionHistory
|
|
|
41
54
|
const baseIdx = riskValues[risk.base] || riskValues[DEFAULT_BASE_RISK];
|
|
42
55
|
if (baseIdx > maxBaseRisk) {
|
|
43
56
|
maxBaseRisk = baseIdx;
|
|
44
|
-
|
|
57
|
+
_worstEvent = event;
|
|
45
58
|
}
|
|
46
59
|
} else if (event.includes('*') && event !== 'onCommand:*') {
|
|
47
60
|
const baseIdx = riskValues['high'];
|
|
48
61
|
if (baseIdx > maxBaseRisk) {
|
|
49
62
|
maxBaseRisk = baseIdx;
|
|
50
|
-
|
|
63
|
+
_worstEvent = event;
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
const contributes = extensionManifest?.contributes || {};
|
|
56
69
|
const commands = contributes?.commands || [];
|
|
57
|
-
const cmdTitles = commands.map(c => (c.title || '').toLowerCase()).join(' ');
|
|
70
|
+
const cmdTitles = commands.map((c) => (c.title || '').toLowerCase()).join(' ');
|
|
58
71
|
|
|
59
72
|
const bundledDeps = extensionManifest?.bundledDependencies || [];
|
|
60
73
|
const bundledStr = Array.isArray(bundledDeps) ? bundledDeps.join(' ') : '';
|
|
61
74
|
|
|
62
|
-
const hasShellKeyword = SHELL_CMDS.some(cmd => cmdTitles.includes(cmd));
|
|
75
|
+
const hasShellKeyword = SHELL_CMDS.some((cmd) => cmdTitles.includes(cmd));
|
|
63
76
|
const hasBunBundled = BUNDLED_BUN_PATTERN.test(bundledStr);
|
|
64
77
|
|
|
65
78
|
const activationEventsStr = activationEvents.join(' ');
|
|
66
|
-
const hasShellInActivationContext = ESCALATION_KEYWORDS.some(kw =>
|
|
79
|
+
const hasShellInActivationContext = ESCALATION_KEYWORDS.some((kw) =>
|
|
80
|
+
activationEventsStr.toLowerCase().includes(kw.toLowerCase())
|
|
81
|
+
);
|
|
67
82
|
|
|
68
83
|
let escalateToCritical = false;
|
|
69
84
|
|
|
@@ -74,22 +89,22 @@ export async function checkActivationEventRisk(extensionManifest, versionHistory
|
|
|
74
89
|
|
|
75
90
|
if (versionHistory.length >= 2) {
|
|
76
91
|
const sizes = versionHistory
|
|
77
|
-
.filter(v => v.assetSize)
|
|
78
|
-
.map(v => v.assetSize)
|
|
92
|
+
.filter((v) => v.assetSize)
|
|
93
|
+
.map((v) => v.assetSize)
|
|
79
94
|
.sort((a, b) => b - a);
|
|
80
95
|
|
|
81
|
-
if (sizes.length >= 2 &&
|
|
96
|
+
if (sizes.length >= 2 && sizes[0] - sizes[sizes.length - 1] > SIZE_DELTA_THRESHOLD) {
|
|
82
97
|
escalateToCritical = true;
|
|
83
98
|
why.push(`HIGH activation event + version size delta > ${SIZE_DELTA_THRESHOLD} bytes`);
|
|
84
99
|
}
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
const priorActivationEvents = priorVersions
|
|
88
|
-
.filter(v => v.activationEvents)
|
|
89
|
-
.flatMap(v => v.activationEvents);
|
|
103
|
+
.filter((v) => v.activationEvents)
|
|
104
|
+
.flatMap((v) => v.activationEvents);
|
|
90
105
|
|
|
91
106
|
if (priorActivationEvents.length > 0) {
|
|
92
|
-
const newEvents = activationEvents.filter(e => !priorActivationEvents.includes(e));
|
|
107
|
+
const newEvents = activationEvents.filter((e) => !priorActivationEvents.includes(e));
|
|
93
108
|
if (newEvents.length > 0) {
|
|
94
109
|
why.push(`First-time activation event(s) added: ${newEvents.join(', ')}`);
|
|
95
110
|
if (!escalateToCritical && maxBaseRisk >= riskValues['high']) {
|
|
@@ -103,7 +118,9 @@ export async function checkActivationEventRisk(extensionManifest, versionHistory
|
|
|
103
118
|
riskLevel = 'critical';
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
if (!riskLevel)
|
|
121
|
+
if (!riskLevel) {
|
|
122
|
+
return { triggered: false, signals: [], riskLevel: null, why: [] };
|
|
123
|
+
}
|
|
107
124
|
|
|
108
125
|
signals.push({
|
|
109
126
|
type: 'ACTIVATION_EVENT_RISK',
|
|
@@ -4,12 +4,14 @@ export async function checkBurstPublish(versionHistory, config = {}) {
|
|
|
4
4
|
const hotPullMinutes = config.hotPullMinutes ?? 20;
|
|
5
5
|
|
|
6
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))
|
|
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
10
|
.sort((a, b) => a.time - b.time);
|
|
11
11
|
|
|
12
|
-
if (entries.length < threshold)
|
|
12
|
+
if (entries.length < threshold) {
|
|
13
|
+
return { triggered: false };
|
|
14
|
+
}
|
|
13
15
|
|
|
14
16
|
const windowMs = windowMinutes * 60 * 1000;
|
|
15
17
|
let burstFound = false;
|
|
@@ -21,14 +23,14 @@ export async function checkBurstPublish(versionHistory, config = {}) {
|
|
|
21
23
|
for (let i = 0; i < entries.length; i++) {
|
|
22
24
|
const start = entries[i].time;
|
|
23
25
|
const end = start + windowMs;
|
|
24
|
-
const inWindow = entries.filter(e => e.time >= start && e.time <= end);
|
|
26
|
+
const inWindow = entries.filter((e) => e.time >= start && e.time <= end);
|
|
25
27
|
|
|
26
28
|
if (inWindow.length >= threshold) {
|
|
27
29
|
burstFound = true;
|
|
28
30
|
burstWindowStart = new Date(start).toISOString();
|
|
29
31
|
burstWindowEnd = new Date(end).toISOString();
|
|
30
32
|
burstVersionCount = inWindow.length;
|
|
31
|
-
burstVersions = inWindow.map(e => e.version);
|
|
33
|
+
burstVersions = inWindow.map((e) => e.version);
|
|
32
34
|
break;
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -45,7 +47,12 @@ export async function checkBurstPublish(versionHistory, config = {}) {
|
|
|
45
47
|
return {
|
|
46
48
|
triggered: burstFound || hotPullDetected,
|
|
47
49
|
burstWindow: burstFound
|
|
48
|
-
? {
|
|
50
|
+
? {
|
|
51
|
+
start: burstWindowStart,
|
|
52
|
+
end: burstWindowEnd,
|
|
53
|
+
versionCount: burstVersionCount,
|
|
54
|
+
versions: burstVersions,
|
|
55
|
+
}
|
|
49
56
|
: null,
|
|
50
57
|
hotPullDetected,
|
|
51
58
|
};
|
|
@@ -35,7 +35,9 @@ const ANTI_ANALYSIS_PATTERNS = [
|
|
|
35
35
|
];
|
|
36
36
|
|
|
37
37
|
function truncateSnippet(str, maxLen = 200) {
|
|
38
|
-
if (!str || str.length <= maxLen)
|
|
38
|
+
if (!str || str.length <= maxLen) {
|
|
39
|
+
return str || '';
|
|
40
|
+
}
|
|
39
41
|
return str.slice(0, maxLen) + '...';
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -46,14 +48,16 @@ export async function checkExfilPattern(extensionFiles = []) {
|
|
|
46
48
|
|
|
47
49
|
for (const file of extensionFiles) {
|
|
48
50
|
const content = typeof file.content === 'string' ? file.content : '';
|
|
49
|
-
if (!content)
|
|
51
|
+
if (!content) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
50
54
|
const path = file.path || '';
|
|
51
55
|
|
|
52
56
|
for (const cp of CREDENTIAL_FILE_PATTERNS) {
|
|
53
57
|
const match = content.match(cp);
|
|
54
58
|
if (match) {
|
|
55
59
|
const snippet = truncateSnippet(match[0]);
|
|
56
|
-
if (!exfilPatterns.some(e => e.includes(snippet))) {
|
|
60
|
+
if (!exfilPatterns.some((e) => e.includes(snippet))) {
|
|
57
61
|
exfilPatterns.push(`${path}: ${snippet}`);
|
|
58
62
|
signals.push({ type: 'CREDENTIAL_FILE_TARGET', pattern: cp.source, file: path });
|
|
59
63
|
}
|
|
@@ -11,7 +11,9 @@ const __dirname = dirname(__filename);
|
|
|
11
11
|
const IOC_PATH = join(__dirname, '..', 'vsix-iocs.json');
|
|
12
12
|
|
|
13
13
|
function loadIOCData() {
|
|
14
|
-
if (iocsLoaded)
|
|
14
|
+
if (iocsLoaded) {
|
|
15
|
+
return iocsData;
|
|
16
|
+
}
|
|
15
17
|
iocsLoaded = true;
|
|
16
18
|
try {
|
|
17
19
|
iocsData = JSON.parse(readFileSync(IOC_PATH, 'utf8'));
|
|
@@ -32,9 +34,17 @@ export function reloadIOCData() {
|
|
|
32
34
|
return loadIOCData();
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
export async function checkKnownIOC(
|
|
37
|
+
export async function checkKnownIOC(
|
|
38
|
+
extensionId,
|
|
39
|
+
version,
|
|
40
|
+
publisherAccount,
|
|
41
|
+
orphanCommits = [],
|
|
42
|
+
versionHistory = []
|
|
43
|
+
) {
|
|
36
44
|
const data = loadIOCData();
|
|
37
|
-
if (!data)
|
|
45
|
+
if (!data) {
|
|
46
|
+
return { triggered: false, matches: [] };
|
|
47
|
+
}
|
|
38
48
|
|
|
39
49
|
const matches = [];
|
|
40
50
|
const iocs = data.iocs || [];
|
|
@@ -43,7 +53,11 @@ export async function checkKnownIOC(extensionId, version, publisherAccount, orph
|
|
|
43
53
|
switch (ioc.type) {
|
|
44
54
|
case 'extensionId': {
|
|
45
55
|
if (ioc.value === extensionId) {
|
|
46
|
-
if (
|
|
56
|
+
if (
|
|
57
|
+
!ioc.maliciousVersions ||
|
|
58
|
+
ioc.maliciousVersions.length === 0 ||
|
|
59
|
+
ioc.maliciousVersions.includes(version)
|
|
60
|
+
) {
|
|
47
61
|
matches.push({
|
|
48
62
|
type: 'extensionId',
|
|
49
63
|
value: extensionId,
|
|
@@ -60,9 +74,10 @@ export async function checkKnownIOC(extensionId, version, publisherAccount, orph
|
|
|
60
74
|
|
|
61
75
|
case 'publisherAccount': {
|
|
62
76
|
if (ioc.value === publisherAccount) {
|
|
63
|
-
const pubTime =
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
const pubTime =
|
|
78
|
+
versionHistory.length > 0
|
|
79
|
+
? new Date(versionHistory[versionHistory.length - 1]?.publishedAt).getTime()
|
|
80
|
+
: null;
|
|
66
81
|
|
|
67
82
|
const windowStart = new Date(ioc.compromiseWindowStart).getTime();
|
|
68
83
|
const windowEnd = ioc.compromiseWindowEnd
|
|
@@ -84,7 +99,7 @@ export async function checkKnownIOC(extensionId, version, publisherAccount, orph
|
|
|
84
99
|
|
|
85
100
|
case 'orphanCommitHash': {
|
|
86
101
|
for (const commit of orphanCommits) {
|
|
87
|
-
if (ioc.value === commit ||
|
|
102
|
+
if (ioc.value === commit || ioc.value === 'PLACEHOLDER_UPDATE_FROM_THREAT_INTEL') {
|
|
88
103
|
continue;
|
|
89
104
|
}
|
|
90
105
|
if (ioc.value && commit && ioc.value.toLowerCase() === commit.toLowerCase()) {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
const GITHUB_COMMIT_SHA_PATTERN =
|
|
1
|
+
const GITHUB_COMMIT_SHA_PATTERN =
|
|
2
|
+
/api\.github\.com\/repos\/[^/]+\/[^/]+\/git\/commits\/[a-f0-9]{40}/;
|
|
2
3
|
const NPX_GIT_URL_PATTERN = /npx\s+.*github\.com.*#[a-f0-9]{8,}/;
|
|
3
4
|
const MCP_KEYWORDS = ['mcp', 'model-context-protocol', 'claude', 'setup', 'init'];
|
|
4
|
-
const
|
|
5
|
-
|
|
5
|
+
const _EXTERNAL_FETCH_PATTERN =
|
|
6
|
+
/(?:https?:\/\/)[^\s"')\]]+(?:\.com|\.io|\.org|\.dev|\.app|\.net)[^\s"')\]]*/;
|
|
7
|
+
const NON_NPMJS_FETCH =
|
|
8
|
+
/(?:fetch|curl|wget)\s*\(?\s*["']https?:\/\/(?!(?:.*npmjs\.org|.*npm\.js\.org|.*github\.com))[^"']+/;
|
|
6
9
|
const BUN_PATTERNS = [/bun\s+install/, /install\s+.*bun/, /\bbunx\b/, /\.bun\/bin\//];
|
|
7
|
-
const
|
|
10
|
+
const _NPX_GIT_SHORT = /npx\s+.*github\.com.*#[a-f0-9]{8,}/;
|
|
8
11
|
|
|
9
12
|
export async function checkOrphanCommitFetch(extensionFiles = []) {
|
|
10
13
|
const signals = [];
|
|
@@ -12,7 +15,9 @@ export async function checkOrphanCommitFetch(extensionFiles = []) {
|
|
|
12
15
|
|
|
13
16
|
for (const file of extensionFiles) {
|
|
14
17
|
const content = typeof file.content === 'string' ? file.content : '';
|
|
15
|
-
if (!content)
|
|
18
|
+
if (!content) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
16
21
|
const path = file.path || '';
|
|
17
22
|
|
|
18
23
|
if (GITHUB_COMMIT_SHA_PATTERN.test(content)) {
|
|
@@ -39,8 +44,7 @@ export async function checkOrphanCommitFetch(extensionFiles = []) {
|
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
const hasMCPKeywords = MCP_KEYWORDS.some(kw =>
|
|
43
|
-
new RegExp(`\\b${kw}\\b`, 'i').test(content));
|
|
47
|
+
const hasMCPKeywords = MCP_KEYWORDS.some((kw) => new RegExp(`\\b${kw}\\b`, 'i').test(content));
|
|
44
48
|
const hasExternalFetch = NON_NPMJS_FETCH.test(content);
|
|
45
49
|
|
|
46
50
|
if (hasMCPKeywords && hasExternalFetch) {
|
|
@@ -1,24 +1,33 @@
|
|
|
1
|
-
export async function checkPublisherAnomaly(
|
|
1
|
+
export async function checkPublisherAnomaly(
|
|
2
|
+
extensionMetadata,
|
|
3
|
+
publisherProfile,
|
|
4
|
+
versionHistory,
|
|
5
|
+
config = {}
|
|
6
|
+
) {
|
|
2
7
|
const signals = [];
|
|
3
8
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
9
|
+
const _crossNamespaceThreshold = config.crossNamespaceThreshold ?? 3;
|
|
10
|
+
const _crossNamespaceDays = config.crossNamespaceDays ?? 14;
|
|
6
11
|
const newAccountAgeDays = config.newAccountAgeDays ?? 30;
|
|
7
12
|
const highInstallThreshold = config.highInstallThreshold ?? 100000;
|
|
8
13
|
const addPublishWindowMinutes = config.addPublishWindowMinutes ?? 15;
|
|
9
14
|
|
|
10
15
|
const versions = versionHistory || [];
|
|
11
|
-
if (versions.length === 0)
|
|
16
|
+
if (versions.length === 0) {
|
|
17
|
+
return { triggered: false, signals: [] };
|
|
18
|
+
}
|
|
12
19
|
|
|
13
|
-
const publishers = [...new Set(versions.map(v => v.publishedBy).filter(Boolean))];
|
|
14
|
-
if (publishers.length === 0)
|
|
20
|
+
const publishers = [...new Set(versions.map((v) => v.publishedBy).filter(Boolean))];
|
|
21
|
+
if (publishers.length === 0) {
|
|
22
|
+
return { triggered: false, signals: [] };
|
|
23
|
+
}
|
|
15
24
|
|
|
16
25
|
const sortedVersions = [...versions]
|
|
17
|
-
.filter(v => v.publishedAt)
|
|
26
|
+
.filter((v) => v.publishedAt)
|
|
18
27
|
.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
|
|
19
28
|
|
|
20
29
|
const extPublisher = publishers[0];
|
|
21
|
-
const allSame = publishers.every(p => p === extPublisher);
|
|
30
|
+
const allSame = publishers.every((p) => p === extPublisher);
|
|
22
31
|
|
|
23
32
|
if (!allSame) {
|
|
24
33
|
for (const pub of publishers) {
|
|
@@ -32,13 +41,18 @@ export async function checkPublisherAnomaly(extensionMetadata, publisherProfile,
|
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
43
|
|
|
35
|
-
const extInstallCount =
|
|
44
|
+
const extInstallCount =
|
|
45
|
+
extensionMetadata?.statistics?.find((s) => s.statisticName === 'install')?.value || 0;
|
|
36
46
|
|
|
37
47
|
const extAgeDays = publisherProfile?.dateCreated
|
|
38
48
|
? (Date.now() - new Date(publisherProfile.dateCreated).getTime()) / (1000 * 60 * 60 * 24)
|
|
39
49
|
: null;
|
|
40
50
|
|
|
41
|
-
if (
|
|
51
|
+
if (
|
|
52
|
+
extAgeDays !== null &&
|
|
53
|
+
extAgeDays < newAccountAgeDays &&
|
|
54
|
+
extInstallCount >= highInstallThreshold
|
|
55
|
+
) {
|
|
42
56
|
signals.push({
|
|
43
57
|
type: 'NEW_ACCOUNT_HIGH_INSTALL',
|
|
44
58
|
accountAgeDays: Math.round(extAgeDays),
|
|
@@ -4,18 +4,32 @@ import { checkActivationEventRisk } from './detectors/activation-event-risk.js';
|
|
|
4
4
|
import { checkOrphanCommitFetch } from './detectors/orphan-commit-fetch.js';
|
|
5
5
|
import { checkKnownIOC } from './detectors/known-ioc.js';
|
|
6
6
|
import { checkExfilPattern } from './detectors/exfil-pattern.js';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
import {
|
|
8
|
+
getExtensionMetadata,
|
|
9
|
+
getVersionHistory,
|
|
10
|
+
getPublisherProfile,
|
|
11
|
+
getOpenVsxMetadata as _getOpenVsxMetadata,
|
|
12
|
+
getOpenVsxVersionHistory,
|
|
13
|
+
} from './marketplace-client.js';
|
|
14
|
+
|
|
15
|
+
const _SEVERITY_SCORE = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
10
16
|
const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical'];
|
|
11
17
|
|
|
12
18
|
export async function vsixScan(extensionId, options = {}) {
|
|
13
19
|
const { publisherId, extensionName } = parseExtensionId(extensionId);
|
|
14
20
|
|
|
15
|
-
const marketplaceMeta =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
21
|
+
const marketplaceMeta =
|
|
22
|
+
options.marketplaceMeta ||
|
|
23
|
+
(options.skipNetwork ? null : await getExtensionMetadata(publisherId, extensionName));
|
|
24
|
+
const marketplaceVersions =
|
|
25
|
+
options.marketplaceVersions ||
|
|
26
|
+
(marketplaceMeta ? await getVersionHistory(publisherId, extensionName) : []);
|
|
27
|
+
const openVsxVersions =
|
|
28
|
+
options.openVsxVersions ||
|
|
29
|
+
(options.skipNetwork ? [] : await getOpenVsxVersionHistory(publisherId, extensionName));
|
|
30
|
+
const publisherProfile =
|
|
31
|
+
options.publisherProfile ||
|
|
32
|
+
(options.skipNetwork ? null : await getPublisherProfile(publisherId));
|
|
19
33
|
|
|
20
34
|
const allVersions = mergeVersionHistories(marketplaceVersions, openVsxVersions);
|
|
21
35
|
const manifest = options.manifest || extractManifest(marketplaceMeta, extensionId);
|
|
@@ -25,7 +39,7 @@ export async function vsixScan(extensionId, options = {}) {
|
|
|
25
39
|
const activationResult = await checkActivationEventRisk(
|
|
26
40
|
manifest,
|
|
27
41
|
allVersions,
|
|
28
|
-
options.priorVersions || []
|
|
42
|
+
options.priorVersions || []
|
|
29
43
|
);
|
|
30
44
|
|
|
31
45
|
const burstResult = await checkBurstPublish(allVersions, config);
|
|
@@ -34,50 +48,82 @@ export async function vsixScan(extensionId, options = {}) {
|
|
|
34
48
|
manifest || {},
|
|
35
49
|
publisherProfile || {},
|
|
36
50
|
allVersions,
|
|
37
|
-
config
|
|
51
|
+
config
|
|
38
52
|
);
|
|
39
53
|
|
|
40
54
|
const orphanResult = await checkOrphanCommitFetch(options.extensionFiles || []);
|
|
41
55
|
|
|
42
56
|
const iocResult = await checkKnownIOC(
|
|
43
57
|
extensionId,
|
|
44
|
-
options.version ||
|
|
58
|
+
options.version ||
|
|
59
|
+
(allVersions.length > 0 ? allVersions[allVersions.length - 1].version : 'unknown'),
|
|
45
60
|
publisherId,
|
|
46
61
|
orphanResult.signals
|
|
47
|
-
.filter(s => s.type === 'ORPHAN_COMMIT_GITHUB_API')
|
|
48
|
-
.map(s => s.indicator),
|
|
49
|
-
allVersions
|
|
62
|
+
.filter((s) => s.type === 'ORPHAN_COMMIT_GITHUB_API')
|
|
63
|
+
.map((s) => s.indicator),
|
|
64
|
+
allVersions
|
|
50
65
|
);
|
|
51
66
|
|
|
52
67
|
const exfilResult = await checkExfilPattern(options.extensionFiles || []);
|
|
53
68
|
|
|
54
69
|
const triggeredSignals = [];
|
|
55
|
-
if (burstResult.triggered)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
if (burstResult.triggered) {
|
|
71
|
+
triggeredSignals.push('VSIX_BURST_PUBLISH');
|
|
72
|
+
}
|
|
73
|
+
if (publisherResult.triggered) {
|
|
74
|
+
triggeredSignals.push('VSIX_PUBLISHER_ANOMALY');
|
|
75
|
+
}
|
|
76
|
+
if (activationResult.triggered) {
|
|
77
|
+
triggeredSignals.push('VSIX_ACTIVATION_EVENT_RISK');
|
|
78
|
+
}
|
|
79
|
+
if (orphanResult.triggered) {
|
|
80
|
+
triggeredSignals.push('VSIX_ORPHAN_COMMIT_FETCH');
|
|
81
|
+
}
|
|
82
|
+
if (iocResult.triggered) {
|
|
83
|
+
triggeredSignals.push('VSIX_KNOWN_IOC');
|
|
84
|
+
}
|
|
85
|
+
if (exfilResult.triggered) {
|
|
86
|
+
triggeredSignals.push('VSIX_EXFIL_PATTERN');
|
|
87
|
+
}
|
|
61
88
|
|
|
62
|
-
if (triggeredSignals.length === 0)
|
|
89
|
+
if (triggeredSignals.length === 0) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
63
92
|
|
|
64
93
|
const registryLabels = [];
|
|
65
|
-
if (marketplaceVersions.length > 0)
|
|
66
|
-
|
|
94
|
+
if (marketplaceVersions.length > 0) {
|
|
95
|
+
registryLabels.push('marketplace');
|
|
96
|
+
}
|
|
97
|
+
if (openVsxVersions.length > 0) {
|
|
98
|
+
registryLabels.push('open-vsx');
|
|
99
|
+
}
|
|
67
100
|
|
|
68
101
|
const maxSeverity = triggeredSignals.reduce((max, s) => {
|
|
69
|
-
if (s === 'VSIX_KNOWN_IOC' || s === 'VSIX_ORPHAN_COMMIT_FETCH')
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
if (s === 'VSIX_KNOWN_IOC' || s === 'VSIX_ORPHAN_COMMIT_FETCH') {
|
|
103
|
+
return Math.max(max, 4);
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
s === 'VSIX_BURST_PUBLISH' ||
|
|
107
|
+
s === 'VSIX_PUBLISHER_ANOMALY' ||
|
|
108
|
+
s === 'VSIX_EXFIL_PATTERN'
|
|
109
|
+
) {
|
|
110
|
+
return Math.max(max, 3);
|
|
111
|
+
}
|
|
112
|
+
if (s === 'VSIX_ACTIVATION_EVENT_RISK') {
|
|
113
|
+
return Math.max(max, 3);
|
|
114
|
+
}
|
|
72
115
|
return max;
|
|
73
116
|
}, 0);
|
|
74
117
|
|
|
75
118
|
const finalSeverity = SEVERITY_LABELS[maxSeverity] || 'high';
|
|
76
119
|
|
|
77
|
-
const latestVersion =
|
|
120
|
+
const latestVersion =
|
|
121
|
+
allVersions.length > 0 ? allVersions[allVersions.length - 1].version : 'unknown';
|
|
78
122
|
let exposureWindowMinutes = null;
|
|
79
123
|
if (burstResult.hotPullDetected && allVersions.length >= 2) {
|
|
80
|
-
const sorted = [...allVersions].sort(
|
|
124
|
+
const sorted = [...allVersions].sort(
|
|
125
|
+
(a, b) => new Date(b.publishedAt) - new Date(a.publishedAt)
|
|
126
|
+
);
|
|
81
127
|
const gap = (new Date(sorted[0].publishedAt) - new Date(sorted[1].publishedAt)) / (1000 * 60);
|
|
82
128
|
exposureWindowMinutes = Math.round(gap);
|
|
83
129
|
}
|
|
@@ -92,7 +138,9 @@ export async function vsixScan(extensionId, options = {}) {
|
|
|
92
138
|
hotPullDetected: burstResult.hotPullDetected,
|
|
93
139
|
publisherSignals: publisherResult.triggered ? publisherResult.signals : null,
|
|
94
140
|
activationEvents: manifest?.activationEvents || null,
|
|
95
|
-
activationRisk: activationResult.triggered
|
|
141
|
+
activationRisk: activationResult.triggered
|
|
142
|
+
? { riskLevel: activationResult.riskLevel, why: activationResult.why }
|
|
143
|
+
: null,
|
|
96
144
|
orphanCommitIndicators: orphanResult.triggered ? orphanResult.indicators : null,
|
|
97
145
|
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
98
146
|
exfilPatterns: exfilResult.triggered ? exfilResult.exfilPatterns : null,
|
|
@@ -101,14 +149,16 @@ export async function vsixScan(extensionId, options = {}) {
|
|
|
101
149
|
|
|
102
150
|
const remediationGuidance = buildRemediation(triggeredSignals, extensionId);
|
|
103
151
|
|
|
104
|
-
return [
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
id: 'VSIX_SCAN',
|
|
155
|
+
severity: finalSeverity,
|
|
156
|
+
title: `VS Code extension risk: ${extensionId}`,
|
|
157
|
+
description: `${triggeredSignals.length} signal(s): ${triggeredSignals.join(', ')}`,
|
|
158
|
+
evidence: JSON.stringify(evidence),
|
|
159
|
+
mitigation: remediationGuidance,
|
|
160
|
+
},
|
|
161
|
+
];
|
|
112
162
|
}
|
|
113
163
|
|
|
114
164
|
function parseExtensionId(id) {
|
|
@@ -135,7 +185,7 @@ function mergeVersionHistories(marketplace, openVsx) {
|
|
|
135
185
|
seen.add(v.version);
|
|
136
186
|
merged.push({ ...v, registries: ['open-vsx'] });
|
|
137
187
|
} else {
|
|
138
|
-
const existing = merged.find(m => m.version === v.version);
|
|
188
|
+
const existing = merged.find((m) => m.version === v.version);
|
|
139
189
|
if (existing) {
|
|
140
190
|
existing.registries.push('open-vsx');
|
|
141
191
|
}
|
|
@@ -145,14 +195,20 @@ function mergeVersionHistories(marketplace, openVsx) {
|
|
|
145
195
|
return merged.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
|
|
146
196
|
}
|
|
147
197
|
|
|
148
|
-
function extractManifest(marketplaceMeta,
|
|
149
|
-
if (!marketplaceMeta?.results?.[0]?.extensions?.[0])
|
|
198
|
+
function extractManifest(marketplaceMeta, _extensionId) {
|
|
199
|
+
if (!marketplaceMeta?.results?.[0]?.extensions?.[0]) {
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
150
202
|
const ext = marketplaceMeta.results[0].extensions[0];
|
|
151
203
|
const manifestStr = ext.galleryApiUrl || ext.manifest;
|
|
152
|
-
if (!manifestStr)
|
|
204
|
+
if (!manifestStr) {
|
|
205
|
+
return {};
|
|
206
|
+
}
|
|
153
207
|
|
|
154
208
|
try {
|
|
155
|
-
if (typeof manifestStr === 'object')
|
|
209
|
+
if (typeof manifestStr === 'object') {
|
|
210
|
+
return manifestStr;
|
|
211
|
+
}
|
|
156
212
|
return JSON.parse(manifestStr);
|
|
157
213
|
} catch {
|
|
158
214
|
return {};
|