shai-scan 0.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/LICENSE +21 -0
- package/README.md +253 -0
- package/package.json +36 -0
- package/src/cli.ts +412 -0
- package/src/db.ts +317 -0
- package/src/lockfile.ts +189 -0
- package/src/scanner.ts +154 -0
- package/src/system.ts +373 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compromised package database for supply chain attacks.
|
|
3
|
+
*
|
|
4
|
+
* Update this file when new campaigns are discovered.
|
|
5
|
+
* Each campaign groups packages by attack wave.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface CompromisedVersion {
|
|
9
|
+
name: string;
|
|
10
|
+
versions: string[];
|
|
11
|
+
ecosystem: "npm" | "pypi";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IOCIndicators {
|
|
15
|
+
domains: string[];
|
|
16
|
+
ips: string[];
|
|
17
|
+
files: string[];
|
|
18
|
+
services: string[];
|
|
19
|
+
npmTokenDescriptions: string[];
|
|
20
|
+
/** Paths where the malware installs persistence hooks */
|
|
21
|
+
persistencePaths: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Campaign {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
date: string;
|
|
28
|
+
attribution: string;
|
|
29
|
+
cve?: string;
|
|
30
|
+
ghsa?: string;
|
|
31
|
+
severity: "critical" | "high" | "medium";
|
|
32
|
+
description: string;
|
|
33
|
+
referenceUrls: string[];
|
|
34
|
+
packages: CompromisedVersion[];
|
|
35
|
+
iocs: IOCIndicators;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Campaign: Mini Shai-Hulud Wave 4 (May 11, 2026) ──────────────────────
|
|
39
|
+
|
|
40
|
+
const MINI_SHAI_HULUD_W4_PACKAGES: CompromisedVersion[] = [
|
|
41
|
+
// ── TanStack (42 packages, 84 versions) ──
|
|
42
|
+
{ name: "@tanstack/react-router", versions: ["1.169.5", "1.169.8"], ecosystem: "npm" },
|
|
43
|
+
{ name: "@tanstack/vue-router", versions: ["1.169.5", "1.169.8"], ecosystem: "npm" },
|
|
44
|
+
{ name: "@tanstack/solid-router", versions: ["1.169.5", "1.169.8"], ecosystem: "npm" },
|
|
45
|
+
{ name: "@tanstack/router-core", versions: ["1.169.5", "1.169.8"], ecosystem: "npm" },
|
|
46
|
+
{ name: "@tanstack/react-start", versions: ["1.167.68", "1.167.71"], ecosystem: "npm" },
|
|
47
|
+
{ name: "@tanstack/router-plugin", versions: ["1.167.38", "1.167.41"], ecosystem: "npm" },
|
|
48
|
+
{ name: "@tanstack/router-utils", versions: ["1.161.11", "1.161.14"], ecosystem: "npm" },
|
|
49
|
+
{ name: "@tanstack/router-cli", versions: ["1.166.46", "1.166.49"], ecosystem: "npm" },
|
|
50
|
+
{ name: "@tanstack/router-devtools", versions: ["1.166.16", "1.166.19"], ecosystem: "npm" },
|
|
51
|
+
{ name: "@tanstack/router-devtools-core", versions: ["1.167.6", "1.167.9"], ecosystem: "npm" },
|
|
52
|
+
{ name: "@tanstack/router-generator", versions: ["1.166.45", "1.166.48"], ecosystem: "npm" },
|
|
53
|
+
{ name: "@tanstack/router-vite-plugin", versions: ["1.166.53", "1.166.56"], ecosystem: "npm" },
|
|
54
|
+
{ name: "@tanstack/router-ssr-query-core", versions: ["1.168.3", "1.168.6"], ecosystem: "npm" },
|
|
55
|
+
{ name: "@tanstack/history", versions: ["1.161.9", "1.161.12"], ecosystem: "npm" },
|
|
56
|
+
{ name: "@tanstack/react-router-devtools", versions: ["1.166.16", "1.166.19"], ecosystem: "npm" },
|
|
57
|
+
{ name: "@tanstack/react-router-ssr-query", versions: ["1.166.15", "1.166.18"], ecosystem: "npm" },
|
|
58
|
+
{ name: "@tanstack/react-start-client", versions: ["1.166.51", "1.166.54"], ecosystem: "npm" },
|
|
59
|
+
{ name: "@tanstack/react-start-server", versions: ["1.166.55", "1.166.58"], ecosystem: "npm" },
|
|
60
|
+
{ name: "@tanstack/react-start-rsc", versions: ["0.0.47", "0.0.50"], ecosystem: "npm" },
|
|
61
|
+
{ name: "@tanstack/solid-router-devtools", versions: ["1.166.16", "1.166.19"], ecosystem: "npm" },
|
|
62
|
+
{ name: "@tanstack/solid-router-ssr-query", versions: ["1.166.15", "1.166.18"], ecosystem: "npm" },
|
|
63
|
+
{ name: "@tanstack/solid-start", versions: ["1.167.65", "1.167.68"], ecosystem: "npm" },
|
|
64
|
+
{ name: "@tanstack/solid-start-client", versions: ["1.166.50", "1.166.53"], ecosystem: "npm" },
|
|
65
|
+
{ name: "@tanstack/solid-start-server", versions: ["1.166.54", "1.166.57"], ecosystem: "npm" },
|
|
66
|
+
{ name: "@tanstack/vue-router-devtools", versions: ["1.166.16", "1.166.19"], ecosystem: "npm" },
|
|
67
|
+
{ name: "@tanstack/vue-router-ssr-query", versions: ["1.166.15", "1.166.18"], ecosystem: "npm" },
|
|
68
|
+
{ name: "@tanstack/vue-start", versions: ["1.167.61", "1.167.64"], ecosystem: "npm" },
|
|
69
|
+
{ name: "@tanstack/vue-start-client", versions: ["1.166.46", "1.166.49"], ecosystem: "npm" },
|
|
70
|
+
{ name: "@tanstack/vue-start-server", versions: ["1.166.50", "1.166.53"], ecosystem: "npm" },
|
|
71
|
+
{ name: "@tanstack/start-client-core", versions: ["1.168.5", "1.168.8"], ecosystem: "npm" },
|
|
72
|
+
{ name: "@tanstack/start-server-core", versions: ["1.167.33", "1.167.36"], ecosystem: "npm" },
|
|
73
|
+
{ name: "@tanstack/start-plugin-core", versions: ["1.169.23", "1.169.26"], ecosystem: "npm" },
|
|
74
|
+
{ name: "@tanstack/start-fn-stubs", versions: ["1.161.9", "1.161.12"], ecosystem: "npm" },
|
|
75
|
+
{ name: "@tanstack/start-storage-context", versions: ["1.166.38", "1.166.41"], ecosystem: "npm" },
|
|
76
|
+
{ name: "@tanstack/start-static-server-functions", versions: ["1.166.44", "1.166.47"], ecosystem: "npm" },
|
|
77
|
+
{ name: "@tanstack/virtual-file-routes", versions: ["1.161.10", "1.161.13"], ecosystem: "npm" },
|
|
78
|
+
{ name: "@tanstack/arktype-adapter", versions: ["1.166.12", "1.166.15"], ecosystem: "npm" },
|
|
79
|
+
{ name: "@tanstack/valibot-adapter", versions: ["1.166.12", "1.166.15"], ecosystem: "npm" },
|
|
80
|
+
{ name: "@tanstack/zod-adapter", versions: ["1.166.12", "1.166.15"], ecosystem: "npm" },
|
|
81
|
+
{ name: "@tanstack/eslint-plugin-router", versions: ["1.161.9", "1.161.12"], ecosystem: "npm" },
|
|
82
|
+
{ name: "@tanstack/eslint-plugin-start", versions: ["0.0.4", "0.0.7"], ecosystem: "npm" },
|
|
83
|
+
{ name: "@tanstack/nitro-v2-vite-plugin", versions: ["1.154.12", "1.154.15"], ecosystem: "npm" },
|
|
84
|
+
|
|
85
|
+
// ── Mistral AI ──
|
|
86
|
+
{ name: "@mistralai/mistralai", versions: ["2.2.2", "2.2.3", "2.2.4"], ecosystem: "npm" },
|
|
87
|
+
{ name: "@mistralai/mistralai-azure", versions: ["1.7.2", "1.7.3"], ecosystem: "npm" },
|
|
88
|
+
{ name: "@mistralai/mistralai-gcp", versions: ["1.7.2", "1.7.3"], ecosystem: "npm" },
|
|
89
|
+
{ name: "mistralai", versions: ["2.4.6"], ecosystem: "pypi" },
|
|
90
|
+
|
|
91
|
+
// ── OpenSearch ──
|
|
92
|
+
{ name: "@opensearch-project/opensearch", versions: ["3.5.3", "3.6.2", "3.7.0", "3.8.0"], ecosystem: "npm" },
|
|
93
|
+
|
|
94
|
+
// ── UiPath ──
|
|
95
|
+
{ name: "@uipath/docsai-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
96
|
+
{ name: "@uipath/packager-tool-apiworkflow", versions: ["0.0.19"], ecosystem: "npm" },
|
|
97
|
+
{ name: "@uipath/packager-tool-workflowcompiler-browser", versions: ["0.0.34"], ecosystem: "npm" },
|
|
98
|
+
{ name: "@uipath/packager-tool-functions", versions: ["0.1.1"], ecosystem: "npm" },
|
|
99
|
+
{ name: "@uipath/agent.sdk", versions: ["0.0.18"], ecosystem: "npm" },
|
|
100
|
+
{ name: "@uipath/filesystem", versions: ["1.0.1"], ecosystem: "npm" },
|
|
101
|
+
{ name: "@uipath/admin-tool", versions: ["0.1.1"], ecosystem: "npm" },
|
|
102
|
+
{ name: "@uipath/llmgw-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
103
|
+
{ name: "@uipath/access-policy-sdk", versions: ["0.3.1"], ecosystem: "npm" },
|
|
104
|
+
{ name: "@uipath/access-policy-tool", versions: ["0.3.1"], ecosystem: "npm" },
|
|
105
|
+
{ name: "@uipath/agent-sdk", versions: ["1.0.2"], ecosystem: "npm" },
|
|
106
|
+
{ name: "@uipath/agent-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
107
|
+
{ name: "@uipath/aops-policy-tool", versions: ["0.3.1"], ecosystem: "npm" },
|
|
108
|
+
{ name: "@uipath/ap-chat", versions: ["1.5.7"], ecosystem: "npm" },
|
|
109
|
+
{ name: "@uipath/api-workflow-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
110
|
+
{ name: "@uipath/apollo-core", versions: ["5.9.2"], ecosystem: "npm" },
|
|
111
|
+
{ name: "@uipath/apollo-react", versions: ["4.24.5"], ecosystem: "npm" },
|
|
112
|
+
{ name: "@uipath/apollo-wind", versions: ["2.16.2"], ecosystem: "npm" },
|
|
113
|
+
{ name: "@uipath/auth", versions: ["1.0.1"], ecosystem: "npm" },
|
|
114
|
+
{ name: "@uipath/case-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
115
|
+
{ name: "@uipath/cli", versions: ["1.0.1"], ecosystem: "npm" },
|
|
116
|
+
{ name: "@uipath/codedagent-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
117
|
+
{ name: "@uipath/codedagents-tool", versions: ["0.1.12"], ecosystem: "npm" },
|
|
118
|
+
{ name: "@uipath/codedapp-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
119
|
+
{ name: "@uipath/common", versions: ["1.0.1"], ecosystem: "npm" },
|
|
120
|
+
{ name: "@uipath/context-grounding-tool", versions: ["0.1.1"], ecosystem: "npm" },
|
|
121
|
+
{ name: "@uipath/data-fabric-tool", versions: ["1.0.2"], ecosystem: "npm" },
|
|
122
|
+
{ name: "@uipath/flow-tool", versions: ["1.0.2"], ecosystem: "npm" },
|
|
123
|
+
{ name: "@uipath/functions-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
124
|
+
{ name: "@uipath/gov-tool", versions: ["0.3.1"], ecosystem: "npm" },
|
|
125
|
+
{ name: "@uipath/identity-tool", versions: ["0.1.1"], ecosystem: "npm" },
|
|
126
|
+
{ name: "@uipath/insights-sdk", versions: ["1.0.1"], ecosystem: "npm" },
|
|
127
|
+
{ name: "@uipath/insights-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
128
|
+
{ name: "@uipath/integrationservice-sdk", versions: ["1.0.2"], ecosystem: "npm" },
|
|
129
|
+
{ name: "@uipath/integrationservice-tool", versions: ["1.0.2"], ecosystem: "npm" },
|
|
130
|
+
{ name: "@uipath/maestro-sdk", versions: ["1.0.1"], ecosystem: "npm" },
|
|
131
|
+
{ name: "@uipath/maestro-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
132
|
+
{ name: "@uipath/orchestrator-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
133
|
+
{ name: "@uipath/packager-tool-bpmn", versions: ["0.0.9"], ecosystem: "npm" },
|
|
134
|
+
{ name: "@uipath/packager-tool-case", versions: ["0.0.9"], ecosystem: "npm" },
|
|
135
|
+
{ name: "@uipath/packager-tool-connector", versions: ["0.0.19"], ecosystem: "npm" },
|
|
136
|
+
{ name: "@uipath/packager-tool-flow", versions: ["0.0.19"], ecosystem: "npm" },
|
|
137
|
+
{ name: "@uipath/packager-tool-webapp", versions: ["1.0.6"], ecosystem: "npm" },
|
|
138
|
+
{ name: "@uipath/packager-tool-workflowcompiler", versions: ["0.0.16"], ecosystem: "npm" },
|
|
139
|
+
{ name: "@uipath/platform-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
140
|
+
{ name: "@uipath/project-packager", versions: ["1.1.16"], ecosystem: "npm" },
|
|
141
|
+
{ name: "@uipath/resource-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
142
|
+
{ name: "@uipath/resourcecatalog-tool", versions: ["0.1.1"], ecosystem: "npm" },
|
|
143
|
+
{ name: "@uipath/resources-tool", versions: ["0.1.11"], ecosystem: "npm" },
|
|
144
|
+
{ name: "@uipath/robot", versions: ["1.3.4"], ecosystem: "npm" },
|
|
145
|
+
{ name: "@uipath/rpa-legacy-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
146
|
+
{ name: "@uipath/rpa-tool", versions: ["0.9.5"], ecosystem: "npm" },
|
|
147
|
+
{ name: "@uipath/solution-packager", versions: ["0.0.35"], ecosystem: "npm" },
|
|
148
|
+
{ name: "@uipath/solution-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
149
|
+
{ name: "@uipath/solutionpackager-sdk", versions: ["1.0.11"], ecosystem: "npm" },
|
|
150
|
+
{ name: "@uipath/solutionpackager-tool-core", versions: ["0.0.34"], ecosystem: "npm" },
|
|
151
|
+
{ name: "@uipath/tasks-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
152
|
+
{ name: "@uipath/telemetry", versions: ["0.0.7"], ecosystem: "npm" },
|
|
153
|
+
{ name: "@uipath/test-manager-tool", versions: ["1.0.2"], ecosystem: "npm" },
|
|
154
|
+
{ name: "@uipath/tool-workflowcompiler", versions: ["0.0.12"], ecosystem: "npm" },
|
|
155
|
+
{ name: "@uipath/traces-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
156
|
+
{ name: "@uipath/ui-widgets-multi-file-upload", versions: ["1.0.1"], ecosystem: "npm" },
|
|
157
|
+
{ name: "@uipath/uipath-python-bridge", versions: ["1.0.1"], ecosystem: "npm" },
|
|
158
|
+
{ name: "@uipath/vertical-solutions-tool", versions: ["1.0.1"], ecosystem: "npm" },
|
|
159
|
+
{ name: "@uipath/vss", versions: ["0.1.6"], ecosystem: "npm" },
|
|
160
|
+
{ name: "@uipath/widget.sdk", versions: ["1.2.3"], ecosystem: "npm" },
|
|
161
|
+
|
|
162
|
+
// ── Squawk (aviation) ──
|
|
163
|
+
{ name: "@squawk/airways", versions: ["0.4.2", "0.4.3", "0.4.5"], ecosystem: "npm" },
|
|
164
|
+
{ name: "@squawk/airport-data", versions: ["0.7.4", "0.7.5", "0.7.7"], ecosystem: "npm" },
|
|
165
|
+
{ name: "@squawk/airports", versions: ["0.6.2", "0.6.3", "0.6.5"], ecosystem: "npm" },
|
|
166
|
+
{ name: "@squawk/airspace", versions: ["0.8.1", "0.8.2", "0.8.4"], ecosystem: "npm" },
|
|
167
|
+
{ name: "@squawk/airspace-data", versions: ["0.5.3", "0.5.4", "0.5.6"], ecosystem: "npm" },
|
|
168
|
+
{ name: "@squawk/airway-data", versions: ["0.5.4", "0.5.5", "0.5.7"], ecosystem: "npm" },
|
|
169
|
+
{ name: "@squawk/fix-data", versions: ["0.6.4", "0.6.5", "0.6.7"], ecosystem: "npm" },
|
|
170
|
+
{ name: "@squawk/fixes", versions: ["0.3.2", "0.3.3", "0.3.5"], ecosystem: "npm" },
|
|
171
|
+
{ name: "@squawk/flight-math", versions: ["0.5.4", "0.5.5", "0.5.7"], ecosystem: "npm" },
|
|
172
|
+
{ name: "@squawk/flightplan", versions: ["0.5.2", "0.5.3", "0.5.5"], ecosystem: "npm" },
|
|
173
|
+
{ name: "@squawk/geo", versions: ["0.4.4", "0.4.5", "0.4.7"], ecosystem: "npm" },
|
|
174
|
+
{ name: "@squawk/icao-registry", versions: ["0.5.2", "0.5.3", "0.5.5"], ecosystem: "npm" },
|
|
175
|
+
{ name: "@squawk/icao-registry-data", versions: ["0.8.4", "0.8.5", "0.8.7"], ecosystem: "npm" },
|
|
176
|
+
{ name: "@squawk/mcp", versions: ["0.9.1", "0.9.2", "0.9.4"], ecosystem: "npm" },
|
|
177
|
+
{ name: "@squawk/navaid-data", versions: ["0.6.4", "0.6.5", "0.6.7"], ecosystem: "npm" },
|
|
178
|
+
{ name: "@squawk/navaids", versions: ["0.4.2", "0.4.3", "0.4.5"], ecosystem: "npm" },
|
|
179
|
+
{ name: "@squawk/notams", versions: ["0.3.6", "0.3.7", "0.3.9"], ecosystem: "npm" },
|
|
180
|
+
{ name: "@squawk/procedure-data", versions: ["0.7.3", "0.7.4", "0.7.6"], ecosystem: "npm" },
|
|
181
|
+
{ name: "@squawk/procedures", versions: ["0.5.2", "0.5.3", "0.5.5"], ecosystem: "npm" },
|
|
182
|
+
{ name: "@squawk/types", versions: ["0.8.1", "0.8.2", "0.8.4"], ecosystem: "npm" },
|
|
183
|
+
{ name: "@squawk/units", versions: ["0.4.3", "0.4.4", "0.4.6"], ecosystem: "npm" },
|
|
184
|
+
{ name: "@squawk/weather", versions: ["0.5.6", "0.5.7", "0.5.9"], ecosystem: "npm" },
|
|
185
|
+
|
|
186
|
+
// ── DraftLab / DraftAuth ──
|
|
187
|
+
{ name: "@draftauth/client", versions: ["0.2.1", "0.2.2"], ecosystem: "npm" },
|
|
188
|
+
{ name: "@draftauth/core", versions: ["0.13.1", "0.13.2"], ecosystem: "npm" },
|
|
189
|
+
{ name: "@draftlab/auth", versions: ["0.24.1", "0.24.2"], ecosystem: "npm" },
|
|
190
|
+
{ name: "@draftlab/auth-router", versions: ["0.5.1", "0.5.2"], ecosystem: "npm" },
|
|
191
|
+
{ name: "@draftlab/db", versions: ["0.16.1", "0.16.2"], ecosystem: "npm" },
|
|
192
|
+
|
|
193
|
+
// ── TallyUI ──
|
|
194
|
+
{ name: "@tallyui/components", versions: ["1.0.1", "1.0.2", "1.0.3"], ecosystem: "npm" },
|
|
195
|
+
{ name: "@tallyui/connector-medusa", versions: ["1.0.1", "1.0.2", "1.0.3"], ecosystem: "npm" },
|
|
196
|
+
{ name: "@tallyui/connector-shopify", versions: ["1.0.1", "1.0.2", "1.0.3"], ecosystem: "npm" },
|
|
197
|
+
{ name: "@tallyui/connector-vendure", versions: ["1.0.1", "1.0.2", "1.0.3"], ecosystem: "npm" },
|
|
198
|
+
{ name: "@tallyui/connector-woocommerce", versions: ["1.0.1", "1.0.2", "1.0.3"], ecosystem: "npm" },
|
|
199
|
+
{ name: "@tallyui/core", versions: ["0.2.1", "0.2.2", "0.2.3"], ecosystem: "npm" },
|
|
200
|
+
{ name: "@tallyui/database", versions: ["1.0.1", "1.0.2", "1.0.3"], ecosystem: "npm" },
|
|
201
|
+
{ name: "@tallyui/pos", versions: ["0.1.1", "0.1.2", "0.1.3"], ecosystem: "npm" },
|
|
202
|
+
{ name: "@tallyui/storage-sqlite", versions: ["0.2.1", "0.2.2", "0.2.3"], ecosystem: "npm" },
|
|
203
|
+
{ name: "@tallyui/theme", versions: ["0.2.1", "0.2.2", "0.2.3"], ecosystem: "npm" },
|
|
204
|
+
|
|
205
|
+
// ── Miscellaneous npm packages ──
|
|
206
|
+
{ name: "safe-action", versions: ["0.8.3", "0.8.4"], ecosystem: "npm" },
|
|
207
|
+
{ name: "cmux-agent-mcp", versions: ["0.1.3", "0.1.4", "0.1.5", "0.1.6", "0.1.7", "0.1.8"], ecosystem: "npm" },
|
|
208
|
+
{ name: "nextmove-mcp", versions: ["0.1.3", "0.1.4", "0.1.5", "0.1.7"], ecosystem: "npm" },
|
|
209
|
+
{ name: "ts-dna", versions: ["3.0.1", "3.0.2", "3.0.4"], ecosystem: "npm" },
|
|
210
|
+
{ name: "cross-stitch", versions: ["1.1.3", "1.1.4", "1.1.6"], ecosystem: "npm" },
|
|
211
|
+
{ name: "git-git-git", versions: ["1.0.8", "1.0.9", "1.0.10", "1.0.12"], ecosystem: "npm" },
|
|
212
|
+
{ name: "git-branch-selector", versions: ["1.3.3", "1.3.4", "1.3.5", "1.3.7"], ecosystem: "npm" },
|
|
213
|
+
{ name: "agentwork-cli", versions: ["0.1.4", "0.1.5"], ecosystem: "npm" },
|
|
214
|
+
{ name: "wot-api", versions: ["0.8.1", "0.8.2", "0.8.4"], ecosystem: "npm" },
|
|
215
|
+
{ name: "ml-toolkit-ts", versions: ["1.0.4", "1.0.5"], ecosystem: "npm" },
|
|
216
|
+
{
|
|
217
|
+
name: "@beproduct/nestjs-auth",
|
|
218
|
+
versions: [
|
|
219
|
+
"0.1.2",
|
|
220
|
+
"0.1.3",
|
|
221
|
+
"0.1.4",
|
|
222
|
+
"0.1.5",
|
|
223
|
+
"0.1.6",
|
|
224
|
+
"0.1.7",
|
|
225
|
+
"0.1.8",
|
|
226
|
+
"0.1.9",
|
|
227
|
+
"0.1.10",
|
|
228
|
+
"0.1.11",
|
|
229
|
+
"0.1.12",
|
|
230
|
+
"0.1.13",
|
|
231
|
+
"0.1.14",
|
|
232
|
+
"0.1.15",
|
|
233
|
+
"0.1.16",
|
|
234
|
+
"0.1.17",
|
|
235
|
+
"0.1.19",
|
|
236
|
+
],
|
|
237
|
+
ecosystem: "npm",
|
|
238
|
+
},
|
|
239
|
+
{ name: "@dirigible-ai/sdk", versions: ["0.6.2", "0.6.3"], ecosystem: "npm" },
|
|
240
|
+
{ name: "@ml-toolkit-ts/preprocessing", versions: ["1.0.2", "1.0.3"], ecosystem: "npm" },
|
|
241
|
+
{ name: "@ml-toolkit-ts/xgboost", versions: ["1.0.3", "1.0.4"], ecosystem: "npm" },
|
|
242
|
+
{ name: "@mesadev/rest", versions: ["0.28.3"], ecosystem: "npm" },
|
|
243
|
+
{ name: "@mesadev/saguaro", versions: ["0.4.22"], ecosystem: "npm" },
|
|
244
|
+
{ name: "@mesadev/sdk", versions: ["0.28.3"], ecosystem: "npm" },
|
|
245
|
+
|
|
246
|
+
// ── Misc. scoped ──
|
|
247
|
+
{
|
|
248
|
+
name: "@taskflow-corp/cli",
|
|
249
|
+
versions: ["0.1.24", "0.1.25", "0.1.26", "0.1.27", "0.1.28", "0.1.29"],
|
|
250
|
+
ecosystem: "npm",
|
|
251
|
+
},
|
|
252
|
+
{ name: "@tolka/cli", versions: ["1.0.2", "1.0.3", "1.0.4", "1.0.6"], ecosystem: "npm" },
|
|
253
|
+
{ name: "@supersurkhet/cli", versions: ["0.0.2", "0.0.3", "0.0.4", "0.0.5", "0.0.6", "0.0.7"], ecosystem: "npm" },
|
|
254
|
+
{ name: "@supersurkhet/sdk", versions: ["0.0.2", "0.0.3", "0.0.4", "0.0.5", "0.0.6", "0.0.7"], ecosystem: "npm" },
|
|
255
|
+
|
|
256
|
+
// ── PyPI ──
|
|
257
|
+
{ name: "guardrails-ai", versions: ["0.10.1"], ecosystem: "pypi" },
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
export const CAMPAIGNS: Campaign[] = [
|
|
261
|
+
{
|
|
262
|
+
id: "mini-shai-hulud-wave4",
|
|
263
|
+
name: "Mini Shai-Hulud Wave 4",
|
|
264
|
+
date: "2026-05-11",
|
|
265
|
+
attribution: "TeamPCP (aka DeadCatx3, PCPcat, ShellForce, CipherForce)",
|
|
266
|
+
cve: "CVE-2026-45321",
|
|
267
|
+
ghsa: "GHSA-g7cv-rxg3-hmpx",
|
|
268
|
+
severity: "critical",
|
|
269
|
+
description:
|
|
270
|
+
"Self-propagating supply chain worm that hijacked GitHub Actions OIDC tokens to publish " +
|
|
271
|
+
"malicious npm packages with valid SLSA Build Level 3 provenance. Stole credentials from " +
|
|
272
|
+
"CI/CD pipelines, cloud providers, cryptocurrency wallets, and installed persistence hooks " +
|
|
273
|
+
"in Claude Code and VS Code. Included a dead-man switch that wipes ~/ if npm tokens are revoked.",
|
|
274
|
+
referenceUrls: [
|
|
275
|
+
"https://thehackernews.com/2026/05/mini-shai-hulud-worm-compromises.html",
|
|
276
|
+
"https://snyk.io/blog/tanstack-npm-packages-compromised/",
|
|
277
|
+
"https://www.stepsecurity.io/blog/mini-shai-hulud-is-back-a-self-spreading-supply-chain-attack-hits-the-npm-ecosystem",
|
|
278
|
+
"https://tanstack.com/blog/npm-supply-chain-compromise-postmortem",
|
|
279
|
+
"https://github.com/TanStack/router/security/advisories/GHSA-g7cv-rxg3-hmpx",
|
|
280
|
+
],
|
|
281
|
+
packages: MINI_SHAI_HULUD_W4_PACKAGES,
|
|
282
|
+
iocs: {
|
|
283
|
+
domains: ["filev2.getsession.org", "api.masscan.cloud", "git-tanstack.com"],
|
|
284
|
+
ips: ["83.142.209.194"],
|
|
285
|
+
files: ["router_init.js", "setup.mjs", "/tmp/transformers.pyz"],
|
|
286
|
+
services: ["gh-token-monitor"],
|
|
287
|
+
npmTokenDescriptions: ["IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner"],
|
|
288
|
+
persistencePaths: [".claude/settings.json", ".claude/settings.local.json", ".config/Code/User/settings.json"],
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
// ── Derived lookup structures ─────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
export interface PackageLookupEntry {
|
|
296
|
+
packageName: string;
|
|
297
|
+
compromisedVersions: Set<string>;
|
|
298
|
+
campaign: Campaign;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Build a map keyed by package name for O(1) lookup. */
|
|
302
|
+
export function buildLookup(): Map<string, PackageLookupEntry[]> {
|
|
303
|
+
const map = new Map<string, PackageLookupEntry[]>();
|
|
304
|
+
for (const campaign of CAMPAIGNS) {
|
|
305
|
+
for (const pkg of campaign.packages) {
|
|
306
|
+
const entry: PackageLookupEntry = {
|
|
307
|
+
packageName: pkg.name,
|
|
308
|
+
compromisedVersions: new Set(pkg.versions),
|
|
309
|
+
campaign,
|
|
310
|
+
};
|
|
311
|
+
const existing = map.get(pkg.name) ?? [];
|
|
312
|
+
existing.push(entry);
|
|
313
|
+
map.set(pkg.name, existing);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return map;
|
|
317
|
+
}
|
package/src/lockfile.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lockfile parsers for npm, pnpm, bun, and Python package managers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
|
|
8
|
+
export interface LockfileResult {
|
|
9
|
+
path: string;
|
|
10
|
+
type: "package-lock" | "bun" | "pnpm" | "requirements" | "pipfile" | "poetry";
|
|
11
|
+
packages: Map<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function parseLockfile(filePath: string): Promise<LockfileResult | null> {
|
|
15
|
+
const name = basename(filePath);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const content = await readFile(filePath, "utf-8");
|
|
19
|
+
|
|
20
|
+
switch (name) {
|
|
21
|
+
case "package-lock.json":
|
|
22
|
+
return parsePackageLock(filePath, content);
|
|
23
|
+
case "bun.lock":
|
|
24
|
+
return parseBunLock(filePath, content);
|
|
25
|
+
case "pnpm-lock.yaml":
|
|
26
|
+
return parsePnpmLock(filePath, content);
|
|
27
|
+
case "requirements.txt":
|
|
28
|
+
return parseRequirements(filePath, content);
|
|
29
|
+
case "Pipfile.lock":
|
|
30
|
+
return parsePipfileLock(filePath, content);
|
|
31
|
+
case "poetry.lock":
|
|
32
|
+
return parsePoetryLock(filePath, content);
|
|
33
|
+
default:
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parsePackageLock(path: string, content: string): LockfileResult | null {
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(content) as Record<string, unknown>;
|
|
44
|
+
const packages = new Map<string, string>();
|
|
45
|
+
|
|
46
|
+
if (data.packages && typeof data.packages === "object") {
|
|
47
|
+
// v2 / v3
|
|
48
|
+
for (const [key, val] of Object.entries(data.packages)) {
|
|
49
|
+
if (!val || typeof val !== "object") continue;
|
|
50
|
+
const pkg = val as Record<string, unknown>;
|
|
51
|
+
if (typeof pkg.version === "string") {
|
|
52
|
+
const name = key.replace(/^node_modules\//, "");
|
|
53
|
+
if (name) {
|
|
54
|
+
packages.set(name, pkg.version);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} else if (data.dependencies && typeof data.dependencies === "object") {
|
|
59
|
+
// v1
|
|
60
|
+
walkPackageLockV1(data.dependencies as Record<string, unknown>, packages);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { path, type: "package-lock", packages };
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function walkPackageLockV1(deps: Record<string, unknown>, out: Map<string, string>): void {
|
|
70
|
+
for (const [name, val] of Object.entries(deps)) {
|
|
71
|
+
if (!val || typeof val !== "object") continue;
|
|
72
|
+
const pkg = val as Record<string, unknown>;
|
|
73
|
+
if (typeof pkg.version === "string") {
|
|
74
|
+
out.set(name, pkg.version);
|
|
75
|
+
}
|
|
76
|
+
if (pkg.dependencies && typeof pkg.dependencies === "object") {
|
|
77
|
+
walkPackageLockV1(pkg.dependencies as Record<string, unknown>, out);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseBunLock(path: string, content: string): LockfileResult | null {
|
|
83
|
+
const packages = new Map<string, string>();
|
|
84
|
+
// Match lines like: "pkg": ["pkg@version",
|
|
85
|
+
const re = /"([^"]+)":\s*\["([^"@]+@([^"]+))"/g;
|
|
86
|
+
let m: RegExpExecArray | null = re.exec(content);
|
|
87
|
+
while (m !== null) {
|
|
88
|
+
const name = m[1];
|
|
89
|
+
const version = m[3];
|
|
90
|
+
if (name && version) {
|
|
91
|
+
packages.set(name, version);
|
|
92
|
+
}
|
|
93
|
+
m = re.exec(content);
|
|
94
|
+
}
|
|
95
|
+
return { path, type: "bun", packages };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parsePnpmLock(path: string, content: string): LockfileResult | null {
|
|
99
|
+
const packages = new Map<string, string>();
|
|
100
|
+
// Extract packages section
|
|
101
|
+
const lines = content.split("\n");
|
|
102
|
+
let inPackages = false;
|
|
103
|
+
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (line.trim() === "packages:") {
|
|
106
|
+
inPackages = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (inPackages) {
|
|
110
|
+
// Stop at next top-level section (no indent or different section)
|
|
111
|
+
if (line.match(/^[a-zA-Z_][a-zA-Z0-9_]*:/) && !line.startsWith(" ")) {
|
|
112
|
+
if (line.trim() !== "packages:") break;
|
|
113
|
+
}
|
|
114
|
+
// Match /pkg-name@version: or /pkg-name@version(peer): lines
|
|
115
|
+
const m = line.match(/\/(?:@[^/]+\/)?([^/@]+)@([^:]+):/);
|
|
116
|
+
if (m) {
|
|
117
|
+
const name = m[1];
|
|
118
|
+
const version = m[2];
|
|
119
|
+
// For scoped packages, reconstruct the name
|
|
120
|
+
const scopedM = line.match(/\/(@[^/]+\/[^/@]+)@([^:]+):/);
|
|
121
|
+
if (scopedM) {
|
|
122
|
+
packages.set(scopedM[1], scopedM[2]);
|
|
123
|
+
} else if (name && version) {
|
|
124
|
+
packages.set(name, version);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { path, type: "pnpm", packages };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseRequirements(path: string, content: string): LockfileResult | null {
|
|
134
|
+
const packages = new Map<string, string>();
|
|
135
|
+
const lines = content.split("\n");
|
|
136
|
+
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
const trimmed = line.trim();
|
|
139
|
+
if (trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
|
|
140
|
+
const m = trimmed.match(/^([a-zA-Z0-9_.-]+)==(.+)$/);
|
|
141
|
+
if (m) {
|
|
142
|
+
packages.set(m[1].toLowerCase(), m[2]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { path, type: "requirements", packages };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parsePipfileLock(path: string, content: string): LockfileResult | null {
|
|
150
|
+
try {
|
|
151
|
+
const data = JSON.parse(content) as Record<string, unknown>;
|
|
152
|
+
const packages = new Map<string, string>();
|
|
153
|
+
|
|
154
|
+
for (const section of ["default", "develop"]) {
|
|
155
|
+
const sec = data[section];
|
|
156
|
+
if (!sec || typeof sec !== "object") continue;
|
|
157
|
+
for (const [name, val] of Object.entries(sec)) {
|
|
158
|
+
if (!val || typeof val !== "object") continue;
|
|
159
|
+
const pkg = val as Record<string, unknown>;
|
|
160
|
+
let version = pkg.version;
|
|
161
|
+
if (typeof version === "string" && version.startsWith("==")) {
|
|
162
|
+
version = version.slice(2);
|
|
163
|
+
}
|
|
164
|
+
if (typeof version === "string") {
|
|
165
|
+
packages.set(name.toLowerCase(), version);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { path, type: "pipfile", packages };
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parsePoetryLock(path: string, content: string): LockfileResult | null {
|
|
177
|
+
const packages = new Map<string, string>();
|
|
178
|
+
const blocks = content.split("[[package]]");
|
|
179
|
+
|
|
180
|
+
for (const block of blocks.slice(1)) {
|
|
181
|
+
const nameM = block.match(/name\s*=\s*"([^"]+)"/);
|
|
182
|
+
const versionM = block.match(/version\s*=\s*"([^"]+)"/);
|
|
183
|
+
if (nameM && versionM) {
|
|
184
|
+
packages.set(nameM[1].toLowerCase(), versionM[1]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { path, type: "poetry", packages };
|
|
189
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan orchestration: lockfile discovery, parsing, and IOC correlation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdir, stat } from "node:fs/promises";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import { buildLookup, type Campaign } from "./db.ts";
|
|
8
|
+
import { parseLockfile } from "./lockfile.ts";
|
|
9
|
+
import { runSystemChecks, type SystemCheckResult } from "./system.ts";
|
|
10
|
+
|
|
11
|
+
export interface ScanOptions {
|
|
12
|
+
lockfilesOnly?: boolean;
|
|
13
|
+
systemOnly?: boolean;
|
|
14
|
+
minSeverity?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LockfileFinding {
|
|
18
|
+
lockfile: string;
|
|
19
|
+
packageName: string;
|
|
20
|
+
version: string;
|
|
21
|
+
campaign: Campaign;
|
|
22
|
+
severity: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ScanResult {
|
|
26
|
+
lockfileFindings: LockfileFinding[];
|
|
27
|
+
systemFindings: SystemCheckResult[];
|
|
28
|
+
lockfilesScanned: number;
|
|
29
|
+
errors: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LOCKFILE_NAMES = new Set([
|
|
33
|
+
"package-lock.json",
|
|
34
|
+
"bun.lock",
|
|
35
|
+
"pnpm-lock.yaml",
|
|
36
|
+
"requirements.txt",
|
|
37
|
+
"Pipfile.lock",
|
|
38
|
+
"poetry.lock",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const MAX_DEPTH = 5;
|
|
42
|
+
|
|
43
|
+
async function findLockfiles(root: string): Promise<string[]> {
|
|
44
|
+
const results: string[] = [];
|
|
45
|
+
|
|
46
|
+
async function walk(dir: string, depth: number): Promise<void> {
|
|
47
|
+
if (depth > MAX_DEPTH) return;
|
|
48
|
+
|
|
49
|
+
let entries: Array<{ name: string; isDirectory(): boolean }>;
|
|
50
|
+
try {
|
|
51
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (entry.name === "node_modules") continue;
|
|
58
|
+
const full = join(dir, entry.name);
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
await walk(full, depth + 1);
|
|
61
|
+
} else if (LOCKFILE_NAMES.has(entry.name)) {
|
|
62
|
+
results.push(full);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const s = await stat(root);
|
|
69
|
+
if (!s.isDirectory()) {
|
|
70
|
+
// If root itself is a lockfile, scan it
|
|
71
|
+
const name = root.split("/").pop() ?? "";
|
|
72
|
+
if (LOCKFILE_NAMES.has(name)) {
|
|
73
|
+
results.push(root);
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await walk(root, 0);
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SEVERITY_ORDER = ["critical", "high", "medium", "low"];
|
|
86
|
+
|
|
87
|
+
function severityIndex(sev: string): number {
|
|
88
|
+
return SEVERITY_ORDER.indexOf(sev.toLowerCase());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function passesMinSeverity(severity: string, minSeverity?: string): boolean {
|
|
92
|
+
if (!minSeverity) return true;
|
|
93
|
+
return severityIndex(severity) <= severityIndex(minSeverity);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function scan(rootPath: string, options: ScanOptions = {}): Promise<ScanResult> {
|
|
97
|
+
const result: ScanResult = {
|
|
98
|
+
lockfileFindings: [],
|
|
99
|
+
systemFindings: [],
|
|
100
|
+
lockfilesScanned: 0,
|
|
101
|
+
errors: [],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const lookup = buildLookup();
|
|
105
|
+
|
|
106
|
+
// Lockfile scanning
|
|
107
|
+
if (!options.systemOnly) {
|
|
108
|
+
const lockfilePaths = await findLockfiles(rootPath);
|
|
109
|
+
|
|
110
|
+
for (const filePath of lockfilePaths) {
|
|
111
|
+
const parsed = await parseLockfile(filePath);
|
|
112
|
+
if (parsed === null) {
|
|
113
|
+
result.errors.push(`Failed to parse lockfile: ${relative(rootPath, filePath) || filePath}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
result.lockfilesScanned++;
|
|
118
|
+
|
|
119
|
+
for (const [pkgName, version] of parsed.packages) {
|
|
120
|
+
const entries = lookup.get(pkgName);
|
|
121
|
+
if (!entries) continue;
|
|
122
|
+
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (entry.compromisedVersions.has(version)) {
|
|
125
|
+
const sev = entry.campaign.severity;
|
|
126
|
+
if (passesMinSeverity(sev, options.minSeverity)) {
|
|
127
|
+
result.lockfileFindings.push({
|
|
128
|
+
lockfile: relative(rootPath, filePath) || filePath,
|
|
129
|
+
packageName: pkgName,
|
|
130
|
+
version,
|
|
131
|
+
campaign: entry.campaign,
|
|
132
|
+
severity: sev,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// System checks
|
|
142
|
+
if (!options.lockfilesOnly) {
|
|
143
|
+
try {
|
|
144
|
+
result.systemFindings = runSystemChecks([rootPath]);
|
|
145
|
+
if (options.minSeverity) {
|
|
146
|
+
result.systemFindings = result.systemFindings.filter((f) => passesMinSeverity(f.severity, options.minSeverity));
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
result.errors.push(`System check error: ${err instanceof Error ? err.message : String(err)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
}
|