@lateos/npm-scan 0.18.1 → 0.18.2
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 -199
- 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 -75
- 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/detectors/tier1-cloud-imds.js +124 -0
- package/backend/detectors/tier1-infostealer.js +36 -0
- package/backend/detectors/tier1-multistage-postinstall.js +81 -0
- package/backend/detectors/tier1-version-confusion.js +107 -0
- 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,61 +1,61 @@
|
|
|
1
|
-
import { MegalodonSignal } from './types.js';
|
|
2
|
-
|
|
3
|
-
const CRED_PATTERNS = [
|
|
4
|
-
{ pattern: /\bAWS_(SECRET_ACCESS_KEY|ACCESS_KEY_ID|SESSION_TOKEN)\b/, label: 'AWS credential' },
|
|
5
|
-
{ pattern: /\bGOOGLE_APPLICATION_CREDENTIALS\b/, label: 'GCP credential' },
|
|
6
|
-
{ pattern: /\bAZURE_(CLIENT_SECRET|TENANT_ID|CLIENT_ID|SUBSCRIPTION_ID)\b/, label: 'Azure credential' },
|
|
7
|
-
{ pattern: /\bGH_(TOKEN|PAT)\b/, label: 'GitHub PAT' },
|
|
8
|
-
{ pattern: /\bGITHUB_TOKEN\b/, label: 'GitHub token' },
|
|
9
|
-
{ pattern: /\bNPM_TOKEN\b/, label: 'npm token' },
|
|
10
|
-
{ pattern: /\bDISCORD_TOKEN\b/, label: 'Discord token' },
|
|
11
|
-
{ pattern: /\bSLACK_TOKEN\b/, label: 'Slack token' },
|
|
12
|
-
{ pattern: /\bSTRIPE_(SECRET|PUBLISHABLE)_KEY\b/, label: 'Stripe key' },
|
|
13
|
-
{ pattern: /\bTWILIO_(ACCOUNT_SID|AUTH_TOKEN)\b/, label: 'Twilio credential' },
|
|
14
|
-
{ pattern: /\bDB_(USERNAME|PASSWORD|URL)\b/, label: 'Database credential' },
|
|
15
|
-
{ pattern: /\bMONGO_(URI|URL|CONNECTION)\b/, label: 'MongoDB connection' },
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
const OUTBOUND_NET_RE = /curl\s+|wget\s+|fetch\s*\(|https?\.request\s*\(|http\.request\s*\(|got\s*\(|axios\s*\.|request\s*\(|node-fetch|\.post\s*\(|\.get\s*\(/i;
|
|
19
|
-
|
|
20
|
-
const TARGET_EXTENSIONS = ['.sh', '.bash', '.yml', '.yaml', '.js'];
|
|
21
|
-
|
|
22
|
-
function isTargetFile(f) {
|
|
23
|
-
const ext = f.path.slice(f.path.lastIndexOf('.')).toLowerCase();
|
|
24
|
-
return TARGET_EXTENSIONS.includes(ext);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function scan(allFiles) {
|
|
28
|
-
const evidence = [];
|
|
29
|
-
const targetFiles = allFiles.filter(isTargetFile);
|
|
30
|
-
|
|
31
|
-
for (const f of targetFiles) {
|
|
32
|
-
const content = f.content;
|
|
33
|
-
let score = 0;
|
|
34
|
-
const matched = [];
|
|
35
|
-
|
|
36
|
-
for (const cp of CRED_PATTERNS) {
|
|
37
|
-
const re = new RegExp(cp.pattern.source, 'gi');
|
|
38
|
-
let m;
|
|
39
|
-
while ((m = re.exec(content)) !== null) {
|
|
40
|
-
if (!matched.some(ex => ex.label === cp.label)) {
|
|
41
|
-
matched.push({ label: cp.label, match: m[0] });
|
|
42
|
-
}
|
|
43
|
-
score += 3;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (score > 0) {
|
|
48
|
-
const hasNetwork = OUTBOUND_NET_RE.test(content);
|
|
49
|
-
if (hasNetwork) {
|
|
50
|
-
evidence.push({
|
|
51
|
-
signal: MegalodonSignal.CREDENTIAL_HARVEST,
|
|
52
|
-
file: f.path,
|
|
53
|
-
excerpt: matched.map(m => m.label).join(', ').slice(0, 120),
|
|
54
|
-
detail: `Credential env vars (${matched.map(m => m.label).join(', ')}) co-occur with outbound network call (score: ${score})`,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return evidence;
|
|
61
|
-
}
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
|
|
3
|
+
const CRED_PATTERNS = [
|
|
4
|
+
{ pattern: /\bAWS_(SECRET_ACCESS_KEY|ACCESS_KEY_ID|SESSION_TOKEN)\b/, label: 'AWS credential' },
|
|
5
|
+
{ pattern: /\bGOOGLE_APPLICATION_CREDENTIALS\b/, label: 'GCP credential' },
|
|
6
|
+
{ pattern: /\bAZURE_(CLIENT_SECRET|TENANT_ID|CLIENT_ID|SUBSCRIPTION_ID)\b/, label: 'Azure credential' },
|
|
7
|
+
{ pattern: /\bGH_(TOKEN|PAT)\b/, label: 'GitHub PAT' },
|
|
8
|
+
{ pattern: /\bGITHUB_TOKEN\b/, label: 'GitHub token' },
|
|
9
|
+
{ pattern: /\bNPM_TOKEN\b/, label: 'npm token' },
|
|
10
|
+
{ pattern: /\bDISCORD_TOKEN\b/, label: 'Discord token' },
|
|
11
|
+
{ pattern: /\bSLACK_TOKEN\b/, label: 'Slack token' },
|
|
12
|
+
{ pattern: /\bSTRIPE_(SECRET|PUBLISHABLE)_KEY\b/, label: 'Stripe key' },
|
|
13
|
+
{ pattern: /\bTWILIO_(ACCOUNT_SID|AUTH_TOKEN)\b/, label: 'Twilio credential' },
|
|
14
|
+
{ pattern: /\bDB_(USERNAME|PASSWORD|URL)\b/, label: 'Database credential' },
|
|
15
|
+
{ pattern: /\bMONGO_(URI|URL|CONNECTION)\b/, label: 'MongoDB connection' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const OUTBOUND_NET_RE = /curl\s+|wget\s+|fetch\s*\(|https?\.request\s*\(|http\.request\s*\(|got\s*\(|axios\s*\.|request\s*\(|node-fetch|\.post\s*\(|\.get\s*\(/i;
|
|
19
|
+
|
|
20
|
+
const TARGET_EXTENSIONS = ['.sh', '.bash', '.yml', '.yaml', '.js'];
|
|
21
|
+
|
|
22
|
+
function isTargetFile(f) {
|
|
23
|
+
const ext = f.path.slice(f.path.lastIndexOf('.')).toLowerCase();
|
|
24
|
+
return TARGET_EXTENSIONS.includes(ext);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function scan(allFiles) {
|
|
28
|
+
const evidence = [];
|
|
29
|
+
const targetFiles = allFiles.filter(isTargetFile);
|
|
30
|
+
|
|
31
|
+
for (const f of targetFiles) {
|
|
32
|
+
const content = f.content;
|
|
33
|
+
let score = 0;
|
|
34
|
+
const matched = [];
|
|
35
|
+
|
|
36
|
+
for (const cp of CRED_PATTERNS) {
|
|
37
|
+
const re = new RegExp(cp.pattern.source, 'gi');
|
|
38
|
+
let m;
|
|
39
|
+
while ((m = re.exec(content)) !== null) {
|
|
40
|
+
if (!matched.some(ex => ex.label === cp.label)) {
|
|
41
|
+
matched.push({ label: cp.label, match: m[0] });
|
|
42
|
+
}
|
|
43
|
+
score += 3;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (score > 0) {
|
|
48
|
+
const hasNetwork = OUTBOUND_NET_RE.test(content);
|
|
49
|
+
if (hasNetwork) {
|
|
50
|
+
evidence.push({
|
|
51
|
+
signal: MegalodonSignal.CREDENTIAL_HARVEST,
|
|
52
|
+
file: f.path,
|
|
53
|
+
excerpt: matched.map(m => m.label).join(', ').slice(0, 120),
|
|
54
|
+
detail: `Credential env vars (${matched.map(m => m.label).join(', ')}) co-occur with outbound network call (score: ${score})`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return evidence;
|
|
61
|
+
}
|
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
import { MegalodonSignal } from './types.js';
|
|
2
|
-
|
|
3
|
-
export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
|
|
4
|
-
const filtered = {};
|
|
5
|
-
for (const [v, t] of Object.entries(times)) {
|
|
6
|
-
if (v === 'created' || v === 'modified') continue;
|
|
7
|
-
filtered[v] = t;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const entries = Object.entries(filtered)
|
|
11
|
-
.filter(([, t]) => t)
|
|
12
|
-
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
13
|
-
.filter(([, ts]) => !Number.isNaN(ts))
|
|
14
|
-
.sort((a, b) => a[1] - b[1]);
|
|
15
|
-
|
|
16
|
-
if (entries.length === 0) {
|
|
17
|
-
return { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const windowMs = windowHours * 3_600_000;
|
|
21
|
-
|
|
22
|
-
for (let i = 0; i < entries.length; i++) {
|
|
23
|
-
const windowStart = entries[i][1];
|
|
24
|
-
const windowEnd = windowStart + windowMs;
|
|
25
|
-
const inWindow = [];
|
|
26
|
-
|
|
27
|
-
for (let j = i; j < entries.length; j++) {
|
|
28
|
-
if (entries[j][1] <= windowEnd) {
|
|
29
|
-
inWindow.push(entries[j][0]);
|
|
30
|
-
} else {
|
|
31
|
-
break;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (inWindow.length >= threshold) {
|
|
36
|
-
let display = inWindow.slice(0, 10);
|
|
37
|
-
let suffix = '';
|
|
38
|
-
if (inWindow.length > 10) {
|
|
39
|
-
suffix = ` +${inWindow.length - 10} more`;
|
|
40
|
-
}
|
|
41
|
-
return {
|
|
42
|
-
triggered: true,
|
|
43
|
-
versionsInWindow: display.join(', ') + suffix,
|
|
44
|
-
windowStartISO: new Date(windowStart).toISOString(),
|
|
45
|
-
_allVersions: inWindow,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function scan(registryMeta) {
|
|
54
|
-
const times = registryMeta?.time || {};
|
|
55
|
-
const result = detectVelocitySpike(times);
|
|
56
|
-
|
|
57
|
-
if (!result.triggered) return [];
|
|
58
|
-
|
|
59
|
-
return [{
|
|
60
|
-
signal: MegalodonSignal.PUBLISH_VELOCITY,
|
|
61
|
-
file: 'registry.npmjs.org',
|
|
62
|
-
excerpt: result.versionsInWindow,
|
|
63
|
-
detail: `Version publish velocity spike: ${result.versionsInWindow} versions in window starting ${result.windowStartISO}`,
|
|
64
|
-
_windowStartISO: result.windowStartISO,
|
|
65
|
-
_allVersions: result._allVersions,
|
|
66
|
-
}];
|
|
67
|
-
}
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
|
|
3
|
+
export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
|
|
4
|
+
const filtered = {};
|
|
5
|
+
for (const [v, t] of Object.entries(times)) {
|
|
6
|
+
if (v === 'created' || v === 'modified') continue;
|
|
7
|
+
filtered[v] = t;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const entries = Object.entries(filtered)
|
|
11
|
+
.filter(([, t]) => t)
|
|
12
|
+
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
13
|
+
.filter(([, ts]) => !Number.isNaN(ts))
|
|
14
|
+
.sort((a, b) => a[1] - b[1]);
|
|
15
|
+
|
|
16
|
+
if (entries.length === 0) {
|
|
17
|
+
return { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const windowMs = windowHours * 3_600_000;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < entries.length; i++) {
|
|
23
|
+
const windowStart = entries[i][1];
|
|
24
|
+
const windowEnd = windowStart + windowMs;
|
|
25
|
+
const inWindow = [];
|
|
26
|
+
|
|
27
|
+
for (let j = i; j < entries.length; j++) {
|
|
28
|
+
if (entries[j][1] <= windowEnd) {
|
|
29
|
+
inWindow.push(entries[j][0]);
|
|
30
|
+
} else {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (inWindow.length >= threshold) {
|
|
36
|
+
let display = inWindow.slice(0, 10);
|
|
37
|
+
let suffix = '';
|
|
38
|
+
if (inWindow.length > 10) {
|
|
39
|
+
suffix = ` +${inWindow.length - 10} more`;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
triggered: true,
|
|
43
|
+
versionsInWindow: display.join(', ') + suffix,
|
|
44
|
+
windowStartISO: new Date(windowStart).toISOString(),
|
|
45
|
+
_allVersions: inWindow,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function scan(registryMeta) {
|
|
54
|
+
const times = registryMeta?.time || {};
|
|
55
|
+
const result = detectVelocitySpike(times);
|
|
56
|
+
|
|
57
|
+
if (!result.triggered) return [];
|
|
58
|
+
|
|
59
|
+
return [{
|
|
60
|
+
signal: MegalodonSignal.PUBLISH_VELOCITY,
|
|
61
|
+
file: 'registry.npmjs.org',
|
|
62
|
+
excerpt: result.versionsInWindow,
|
|
63
|
+
detail: `Version publish velocity spike: ${result.versionsInWindow} versions in window starting ${result.windowStartISO}`,
|
|
64
|
+
_windowStartISO: result.windowStartISO,
|
|
65
|
+
_allVersions: result._allVersions,
|
|
66
|
+
}];
|
|
67
|
+
}
|
|
@@ -1,124 +1,124 @@
|
|
|
1
|
-
import { MegalodonSignal } from './types.js';
|
|
2
|
-
|
|
3
|
-
export async function scan(registryMeta, velocityResult) {
|
|
4
|
-
const evidence = [];
|
|
5
|
-
const versions = registryMeta?.versions || {};
|
|
6
|
-
const timeMap = registryMeta?.time || {};
|
|
7
|
-
|
|
8
|
-
const filteredTimes = {};
|
|
9
|
-
for (const [v, t] of Object.entries(timeMap)) {
|
|
10
|
-
if (v === 'created' || v === 'modified') continue;
|
|
11
|
-
if (t) filteredTimes[v] = t;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const sortedVersions = Object.entries(filteredTimes)
|
|
15
|
-
.filter(([, t]) => t && !Number.isNaN(new Date(t).getTime()))
|
|
16
|
-
.sort((a, b) => new Date(a[1]).getTime() - new Date(b[1]).getTime())
|
|
17
|
-
.map(([v]) => v);
|
|
18
|
-
|
|
19
|
-
if (sortedVersions.length === 0) return [];
|
|
20
|
-
|
|
21
|
-
if (velocityResult?.triggered) {
|
|
22
|
-
const windowStartISO = velocityResult.windowStartISO;
|
|
23
|
-
const allInWindow = velocityResult._allVersions || [];
|
|
24
|
-
|
|
25
|
-
const priorPublishers = new Set();
|
|
26
|
-
for (const v of sortedVersions) {
|
|
27
|
-
if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime()) break;
|
|
28
|
-
const user = versions[v]?._npmUser?.name;
|
|
29
|
-
if (user) priorPublishers.add(user);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (priorPublishers.size === 0 && allInWindow.length > 0) {
|
|
33
|
-
const firstUser = versions[allInWindow[0]]?._npmUser?.name;
|
|
34
|
-
if (firstUser) priorPublishers.add(firstUser);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const suspiciousPublishers = [];
|
|
38
|
-
const affectedVersions = [];
|
|
39
|
-
for (const v of allInWindow) {
|
|
40
|
-
const user = versions[v]?._npmUser?.name;
|
|
41
|
-
if (user && !priorPublishers.has(user)) {
|
|
42
|
-
if (!suspiciousPublishers.includes(user)) suspiciousPublishers.push(user);
|
|
43
|
-
if (!affectedVersions.includes(v)) affectedVersions.push(v);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (suspiciousPublishers.length > 0) {
|
|
48
|
-
const detail = `Drift detected: known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in versions [${affectedVersions.join(', ')}]`;
|
|
49
|
-
|
|
50
|
-
const firstSuspiciousVer = allInWindow.find(v => affectedVersions.includes(v));
|
|
51
|
-
let ageNote = '';
|
|
52
|
-
if (firstSuspiciousVer && suspiciousPublishers[0]) {
|
|
53
|
-
ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[firstSuspiciousVer]);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
evidence.push({
|
|
57
|
-
signal: MegalodonSignal.PUBLISHER_DRIFT,
|
|
58
|
-
file: 'registry.npmjs.org',
|
|
59
|
-
excerpt: `publisher drift: ${suspiciousPublishers.join(', ')}`,
|
|
60
|
-
detail: detail + (ageNote ? ' | ' + ageNote : ''),
|
|
61
|
-
_severityHint: 'HIGH',
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
} else {
|
|
65
|
-
if (sortedVersions.length < 4) return [];
|
|
66
|
-
|
|
67
|
-
const last3 = sortedVersions.slice(-3);
|
|
68
|
-
const prior = sortedVersions.slice(0, -3);
|
|
69
|
-
|
|
70
|
-
const priorPublishers = new Set();
|
|
71
|
-
for (const v of prior) {
|
|
72
|
-
const user = versions[v]?._npmUser?.name;
|
|
73
|
-
if (user) priorPublishers.add(user);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const suspiciousPublishers = [];
|
|
77
|
-
const affectedVersions = [];
|
|
78
|
-
for (const v of last3) {
|
|
79
|
-
const user = versions[v]?._npmUser?.name;
|
|
80
|
-
if (user && !priorPublishers.has(user)) {
|
|
81
|
-
if (!suspiciousPublishers.includes(user)) suspiciousPublishers.push(user);
|
|
82
|
-
if (!affectedVersions.includes(v)) affectedVersions.push(v);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (suspiciousPublishers.length > 0) {
|
|
87
|
-
const detail = `Drift (fallback): known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in last 3 versions [${affectedVersions.join(', ')}]`;
|
|
88
|
-
|
|
89
|
-
let ageNote = '';
|
|
90
|
-
if (suspiciousPublishers[0] && affectedVersions[0]) {
|
|
91
|
-
ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[affectedVersions[0]]);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
evidence.push({
|
|
95
|
-
signal: MegalodonSignal.PUBLISHER_DRIFT,
|
|
96
|
-
file: 'registry.npmjs.org',
|
|
97
|
-
excerpt: `publisher drift: ${suspiciousPublishers.join(', ')}`,
|
|
98
|
-
detail: detail + (ageNote ? ' | ' + ageNote : ''),
|
|
99
|
-
_severityHint: 'MEDIUM',
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return evidence;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function checkAccountAge(npmUser, firstSuspiciousTime) {
|
|
108
|
-
try {
|
|
109
|
-
const url = `https://registry.npmjs.org/-/user/org.couchdb.user/${encodeURIComponent(npmUser)}`;
|
|
110
|
-
const res = await fetch(url);
|
|
111
|
-
if (!res.ok) return '';
|
|
112
|
-
const data = await res.json();
|
|
113
|
-
const created = data?.date;
|
|
114
|
-
if (!created) return '';
|
|
115
|
-
const createdDate = new Date(created).getTime();
|
|
116
|
-
const firstPub = new Date(firstSuspiciousTime).getTime();
|
|
117
|
-
const daysDiff = (firstPub - createdDate) / (1000 * 60 * 60 * 24);
|
|
118
|
-
if (!Number.isNaN(daysDiff) && daysDiff >= 0 && daysDiff <= 30) {
|
|
119
|
-
return `Publisher account created ${Math.round(daysDiff)} days before first suspicious publish`;
|
|
120
|
-
}
|
|
121
|
-
} catch {
|
|
122
|
-
}
|
|
123
|
-
return '';
|
|
124
|
-
}
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
|
|
3
|
+
export async function scan(registryMeta, velocityResult) {
|
|
4
|
+
const evidence = [];
|
|
5
|
+
const versions = registryMeta?.versions || {};
|
|
6
|
+
const timeMap = registryMeta?.time || {};
|
|
7
|
+
|
|
8
|
+
const filteredTimes = {};
|
|
9
|
+
for (const [v, t] of Object.entries(timeMap)) {
|
|
10
|
+
if (v === 'created' || v === 'modified') continue;
|
|
11
|
+
if (t) filteredTimes[v] = t;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sortedVersions = Object.entries(filteredTimes)
|
|
15
|
+
.filter(([, t]) => t && !Number.isNaN(new Date(t).getTime()))
|
|
16
|
+
.sort((a, b) => new Date(a[1]).getTime() - new Date(b[1]).getTime())
|
|
17
|
+
.map(([v]) => v);
|
|
18
|
+
|
|
19
|
+
if (sortedVersions.length === 0) return [];
|
|
20
|
+
|
|
21
|
+
if (velocityResult?.triggered) {
|
|
22
|
+
const windowStartISO = velocityResult.windowStartISO;
|
|
23
|
+
const allInWindow = velocityResult._allVersions || [];
|
|
24
|
+
|
|
25
|
+
const priorPublishers = new Set();
|
|
26
|
+
for (const v of sortedVersions) {
|
|
27
|
+
if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime()) break;
|
|
28
|
+
const user = versions[v]?._npmUser?.name;
|
|
29
|
+
if (user) priorPublishers.add(user);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (priorPublishers.size === 0 && allInWindow.length > 0) {
|
|
33
|
+
const firstUser = versions[allInWindow[0]]?._npmUser?.name;
|
|
34
|
+
if (firstUser) priorPublishers.add(firstUser);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const suspiciousPublishers = [];
|
|
38
|
+
const affectedVersions = [];
|
|
39
|
+
for (const v of allInWindow) {
|
|
40
|
+
const user = versions[v]?._npmUser?.name;
|
|
41
|
+
if (user && !priorPublishers.has(user)) {
|
|
42
|
+
if (!suspiciousPublishers.includes(user)) suspiciousPublishers.push(user);
|
|
43
|
+
if (!affectedVersions.includes(v)) affectedVersions.push(v);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (suspiciousPublishers.length > 0) {
|
|
48
|
+
const detail = `Drift detected: known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in versions [${affectedVersions.join(', ')}]`;
|
|
49
|
+
|
|
50
|
+
const firstSuspiciousVer = allInWindow.find(v => affectedVersions.includes(v));
|
|
51
|
+
let ageNote = '';
|
|
52
|
+
if (firstSuspiciousVer && suspiciousPublishers[0]) {
|
|
53
|
+
ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[firstSuspiciousVer]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
evidence.push({
|
|
57
|
+
signal: MegalodonSignal.PUBLISHER_DRIFT,
|
|
58
|
+
file: 'registry.npmjs.org',
|
|
59
|
+
excerpt: `publisher drift: ${suspiciousPublishers.join(', ')}`,
|
|
60
|
+
detail: detail + (ageNote ? ' | ' + ageNote : ''),
|
|
61
|
+
_severityHint: 'HIGH',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
if (sortedVersions.length < 4) return [];
|
|
66
|
+
|
|
67
|
+
const last3 = sortedVersions.slice(-3);
|
|
68
|
+
const prior = sortedVersions.slice(0, -3);
|
|
69
|
+
|
|
70
|
+
const priorPublishers = new Set();
|
|
71
|
+
for (const v of prior) {
|
|
72
|
+
const user = versions[v]?._npmUser?.name;
|
|
73
|
+
if (user) priorPublishers.add(user);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const suspiciousPublishers = [];
|
|
77
|
+
const affectedVersions = [];
|
|
78
|
+
for (const v of last3) {
|
|
79
|
+
const user = versions[v]?._npmUser?.name;
|
|
80
|
+
if (user && !priorPublishers.has(user)) {
|
|
81
|
+
if (!suspiciousPublishers.includes(user)) suspiciousPublishers.push(user);
|
|
82
|
+
if (!affectedVersions.includes(v)) affectedVersions.push(v);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (suspiciousPublishers.length > 0) {
|
|
87
|
+
const detail = `Drift (fallback): known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in last 3 versions [${affectedVersions.join(', ')}]`;
|
|
88
|
+
|
|
89
|
+
let ageNote = '';
|
|
90
|
+
if (suspiciousPublishers[0] && affectedVersions[0]) {
|
|
91
|
+
ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[affectedVersions[0]]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
evidence.push({
|
|
95
|
+
signal: MegalodonSignal.PUBLISHER_DRIFT,
|
|
96
|
+
file: 'registry.npmjs.org',
|
|
97
|
+
excerpt: `publisher drift: ${suspiciousPublishers.join(', ')}`,
|
|
98
|
+
detail: detail + (ageNote ? ' | ' + ageNote : ''),
|
|
99
|
+
_severityHint: 'MEDIUM',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return evidence;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function checkAccountAge(npmUser, firstSuspiciousTime) {
|
|
108
|
+
try {
|
|
109
|
+
const url = `https://registry.npmjs.org/-/user/org.couchdb.user/${encodeURIComponent(npmUser)}`;
|
|
110
|
+
const res = await fetch(url);
|
|
111
|
+
if (!res.ok) return '';
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
const created = data?.date;
|
|
114
|
+
if (!created) return '';
|
|
115
|
+
const createdDate = new Date(created).getTime();
|
|
116
|
+
const firstPub = new Date(firstSuspiciousTime).getTime();
|
|
117
|
+
const daysDiff = (firstPub - createdDate) / (1000 * 60 * 60 * 24);
|
|
118
|
+
if (!Number.isNaN(daysDiff) && daysDiff >= 0 && daysDiff <= 30) {
|
|
119
|
+
return `Publisher account created ${Math.round(daysDiff)} days before first suspicious publish`;
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export async function scan(registryMeta) {
|
|
2
|
-
return [];
|
|
3
|
-
}
|
|
1
|
+
export async function scan(registryMeta) {
|
|
2
|
+
return [];
|
|
3
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export async function scan(pkgJson, registryMeta) {
|
|
2
|
-
return [];
|
|
3
|
-
}
|
|
1
|
+
export async function scan(pkgJson, registryMeta) {
|
|
2
|
+
return [];
|
|
3
|
+
}
|