@lateos/npm-scan 0.18.2 → 1.0.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +265 -233
  2. package/LICENSING.md +19 -19
  3. package/README.de.md +708 -708
  4. package/README.fr.md +707 -707
  5. package/README.ja.md +704 -704
  6. package/README.md +861 -826
  7. package/README.zh.md +708 -708
  8. package/VALIDATION.md +92 -0
  9. package/backend/cra.js +68 -68
  10. package/backend/db/pg-schema.sql +155 -0
  11. package/backend/db/schema.sql +32 -32
  12. package/backend/db.js +88 -88
  13. package/backend/detectors/atk-001-lifecycle.js +17 -17
  14. package/backend/detectors/atk-002-obfusc.js +261 -261
  15. package/backend/detectors/atk-003-creds.js +13 -13
  16. package/backend/detectors/atk-004-persist.js +13 -13
  17. package/backend/detectors/atk-005-exfil.js +13 -13
  18. package/backend/detectors/atk-006-depconf.js +14 -14
  19. package/backend/detectors/atk-007-typosquat.js +34 -34
  20. package/backend/detectors/atk-008-tarball-tamper.js +91 -91
  21. package/backend/detectors/atk-009-dormant-trigger.js +62 -62
  22. package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
  23. package/backend/detectors/atk-011-transitive-prop.js +76 -76
  24. package/backend/detectors/config/thresholds.js +66 -0
  25. package/backend/detectors/config/whitelist.json +74 -0
  26. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
  27. package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
  28. package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
  29. package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
  30. package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
  31. package/backend/detectors/hf-impersonation/index.js +396 -396
  32. package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
  33. package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
  34. package/backend/detectors/hf-impersonation/simhash.js +46 -46
  35. package/backend/detectors/index.js +87 -81
  36. package/backend/detectors/lib/ast-patterns.js +21 -0
  37. package/backend/detectors/lib/entropy-analyzer.js +24 -0
  38. package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
  39. package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
  40. package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
  41. package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
  42. package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
  43. package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
  44. package/backend/detectors/megalodon/index.js +80 -80
  45. package/backend/detectors/megalodon/types.js +9 -9
  46. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
  47. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
  48. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
  49. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
  50. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
  51. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
  52. package/backend/detectors/mini-shai-hulud/index.js +118 -118
  53. package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
  54. package/backend/detectors/tier1-binary-embed.js +34 -5
  55. package/backend/detectors/tier1-obfuscation-heuristics.js +156 -0
  56. package/backend/detectors/tier1-slsa-attestation.js +12 -0
  57. package/backend/detectors/tier1-version-anomaly.js +187 -0
  58. package/backend/detectors.test.js +88 -0
  59. package/backend/fetch.js +175 -175
  60. package/backend/index.js +4 -4
  61. package/backend/license.js +89 -89
  62. package/backend/lockfile.js +379 -379
  63. package/backend/pdf.js +245 -245
  64. package/backend/policy.js +193 -193
  65. package/backend/report.js +254 -254
  66. package/backend/sbom.js +66 -66
  67. package/backend/scripts/analyze-false-positives.js +146 -0
  68. package/backend/scripts/analyze-validation.js +151 -0
  69. package/backend/scripts/detect-false-positives.js +93 -0
  70. package/backend/scripts/fetch-top-packages.js +129 -0
  71. package/backend/scripts/validate-detectors.js +142 -0
  72. package/backend/siem/cef.js +32 -32
  73. package/backend/siem/ecs.js +40 -40
  74. package/backend/siem/index.js +18 -18
  75. package/backend/siem/qradar.js +56 -56
  76. package/backend/siem/sentinel.js +27 -27
  77. package/backend/tests-d5-enhanced.test.js +46 -0
  78. package/backend/tests-d6-version-anomaly.test.js +58 -0
  79. package/backend/tests-d6.test.js +116 -0
  80. package/backend/tests-d6c.test.js +106 -0
  81. package/backend/tests-d7-obfuscation.test.js +91 -0
  82. package/backend/tests.test.js +898 -0
  83. package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
  84. package/backend/vsix-scan/detectors/burst-publish.js +52 -52
  85. package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
  86. package/backend/vsix-scan/detectors/known-ioc.js +105 -105
  87. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
  88. package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
  89. package/backend/vsix-scan/index.js +183 -183
  90. package/backend/vsix-scan/marketplace-client.js +145 -145
  91. package/backend/vsix-scan/vsix-iocs.json +31 -31
  92. package/cli/cli.js +458 -458
  93. package/package.json +74 -57
  94. package/.dockerignore +0 -20
  95. package/.husky/pre-commit +0 -1
  96. package/SECURITY.md +0 -73
  97. package/deploy/helm/npm-scan/Chart.yaml +0 -22
  98. package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
  99. package/deploy/helm/npm-scan/templates/api.yaml +0 -94
  100. package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
  101. package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
  102. package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
  103. package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
  104. package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
  105. package/deploy/helm/npm-scan/values.yaml +0 -103
  106. package/scripts/download-corpus.js +0 -30
  107. package/scripts/gen-mal-corpus.js +0 -35
  108. package/scripts/generate-campaign-fixtures.js +0 -170
  109. package/src/config/top-5000.json +0 -87
  110. package/test/fixtures/lockfiles/npm-lock.json +0 -69
  111. package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
  112. package/test/fixtures/lockfiles/yarn.lock +0 -104
  113. package/test/fixtures/mock-data.js +0 -69
@@ -1,105 +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
- }
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
+ }
@@ -1,69 +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
- }
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
+ }
@@ -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
+ }