npmcordon 0.0.0-stage → 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 +13 -2
- package/dist/cordon.js +1250 -0
- package/package.json +33 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sahil Kumar Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# npmcordon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The command line guard of [Cordon](https://github.com/SahilKumar75/cordon), a circuit breaker for npm dependencies. It reads the Sealed list from the SealRegistry contract and blocks sealed package versions in your lockfile before npm installs or runs them.
|
|
4
|
+
|
|
5
|
+
Testnet prototype: npm lockfile v2 and v3 only, and one operator runs every detector and the guardian key today.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
export CHAIN_ID=84532 RPC_URL=https://sepolia.base.org
|
|
9
|
+
export REGISTRY_ADDRESS=0x07DCC100d48fBA912fEf3e14F61d1162596a0E8F REGISTRY_START_BLOCK=55415999
|
|
10
|
+
export GUARD_CACHE_DIR=$HOME/.cordon
|
|
11
|
+
npx npmcordon check --lockfile path/to/project
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Exit code 0 means allowed, 1 blocked, 2 unavailable. Other commands: install, audit, rollback, sidecar. Details, limits and the demo are in the [repository](https://github.com/SahilKumar75/cordon).
|
package/dist/cordon.js
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { parseArgs, promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
// src/audit.ts
|
|
8
|
+
import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
|
|
9
|
+
import { basename as basename2, dirname as dirname3, isAbsolute, join as join4 } from "node:path";
|
|
10
|
+
import { Parser, list as listTar } from "tar";
|
|
11
|
+
|
|
12
|
+
// ../shared/src/constants.ts
|
|
13
|
+
var productName = "Cordon";
|
|
14
|
+
var ecosystem = "npm";
|
|
15
|
+
var entryStatusNames = ["Sealed", "Unsealed", "Cleared", "Suspect"];
|
|
16
|
+
var detectorIds = ["staticScripts", "sandboxCanary", "advisoryFeed"];
|
|
17
|
+
var timelineKinds = ["published", "verdict", "sealed", "guardBlocked", "unsealed", "resealed", "promoted", "cleared"];
|
|
18
|
+
var guardOutcomes = ["allowed", "blocked", "unavailable"];
|
|
19
|
+
var listSources = ["chain", "snapshot", "none"];
|
|
20
|
+
var zeroHash = `0x${"0".repeat(64)}`;
|
|
21
|
+
|
|
22
|
+
// ../shared/src/schemas.ts
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
var hex32Schema = z.string().regex(/^0x[0-9a-f]{64}$/);
|
|
25
|
+
var addressSchema = z.string().regex(/^0x[0-9a-fA-F]{40}$/);
|
|
26
|
+
var signatureSchema = z.string().regex(/^0x[0-9a-fA-F]+$/);
|
|
27
|
+
var packageVersionSchema = z.object({
|
|
28
|
+
name: z.string().min(1).max(214),
|
|
29
|
+
version: z.string().min(1)
|
|
30
|
+
});
|
|
31
|
+
var scanRequestSchema = packageVersionSchema.extend({
|
|
32
|
+
tarballUrl: z.string().url(),
|
|
33
|
+
integrity: z.string().min(1),
|
|
34
|
+
publishedAt: z.string().datetime()
|
|
35
|
+
});
|
|
36
|
+
var replayRequestSchema = z.object({
|
|
37
|
+
advisoryId: z.string().min(1)
|
|
38
|
+
});
|
|
39
|
+
var replayTimelineSchema = z.object({
|
|
40
|
+
replay: z.literal(true),
|
|
41
|
+
advisoryId: z.string().min(1),
|
|
42
|
+
advisoryUrl: z.string().url(),
|
|
43
|
+
packages: z.array(packageVersionSchema).min(1),
|
|
44
|
+
events: z.array(
|
|
45
|
+
z.object({
|
|
46
|
+
label: z.string().min(1),
|
|
47
|
+
at: z.string().datetime(),
|
|
48
|
+
sourceUrl: z.string().url()
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
});
|
|
52
|
+
var findingSchema = z.object({
|
|
53
|
+
rule: z.string().min(1),
|
|
54
|
+
detail: z.string(),
|
|
55
|
+
file: z.string().nullable()
|
|
56
|
+
});
|
|
57
|
+
var evidenceSchema = packageVersionSchema.extend({
|
|
58
|
+
detector: z.enum(detectorIds),
|
|
59
|
+
integrity: z.string().min(1),
|
|
60
|
+
malicious: z.boolean(),
|
|
61
|
+
simulated: z.boolean(),
|
|
62
|
+
findings: z.array(findingSchema),
|
|
63
|
+
advisoryUrl: z.string().url().nullable(),
|
|
64
|
+
observedAt: z.string().datetime()
|
|
65
|
+
});
|
|
66
|
+
var verdictSchema = z.object({
|
|
67
|
+
key: hex32Schema,
|
|
68
|
+
detector: z.enum(detectorIds),
|
|
69
|
+
detectorAddress: addressSchema,
|
|
70
|
+
evidenceHash: hex32Schema,
|
|
71
|
+
malicious: z.boolean(),
|
|
72
|
+
simulated: z.boolean(),
|
|
73
|
+
txHash: hex32Schema.nullable(),
|
|
74
|
+
submittedAt: z.string().datetime()
|
|
75
|
+
});
|
|
76
|
+
var attestationSchema = verdictSchema.merge(packageVersionSchema);
|
|
77
|
+
var detectorInfoSchema = z.object({
|
|
78
|
+
id: z.enum(detectorIds),
|
|
79
|
+
address: addressSchema,
|
|
80
|
+
simulated: z.boolean()
|
|
81
|
+
});
|
|
82
|
+
var registryChangeSchema = packageVersionSchema.extend({
|
|
83
|
+
seq: z.number().int().nonnegative(),
|
|
84
|
+
publishedAt: z.string().datetime(),
|
|
85
|
+
tarballUrl: z.string().url(),
|
|
86
|
+
integrity: z.string().min(1)
|
|
87
|
+
});
|
|
88
|
+
var scanResponseSchema = packageVersionSchema.extend({
|
|
89
|
+
key: hex32Schema,
|
|
90
|
+
verdicts: z.array(verdictSchema),
|
|
91
|
+
failures: z.array(z.object({ detector: z.enum(detectorIds), error: z.string() }))
|
|
92
|
+
});
|
|
93
|
+
var sealEntrySchema = packageVersionSchema.extend({
|
|
94
|
+
key: hex32Schema,
|
|
95
|
+
status: z.enum(entryStatusNames),
|
|
96
|
+
sealedAt: z.string().datetime().nullable(),
|
|
97
|
+
votes: z.number().int().nonnegative(),
|
|
98
|
+
suspectUntil: z.string().datetime().nullable()
|
|
99
|
+
});
|
|
100
|
+
var sealedSnapshotSchema = z.object({
|
|
101
|
+
product: z.literal(productName),
|
|
102
|
+
chainId: z.number().int().positive(),
|
|
103
|
+
registry: addressSchema,
|
|
104
|
+
blockNumber: z.number().int().nonnegative(),
|
|
105
|
+
generatedAt: z.string().datetime(),
|
|
106
|
+
entries: z.array(sealEntrySchema),
|
|
107
|
+
timelineHeads: z.array(z.object({ key: hex32Schema, hash: hex32Schema }))
|
|
108
|
+
});
|
|
109
|
+
var signedSnapshotSchema = z.object({
|
|
110
|
+
snapshot: sealedSnapshotSchema,
|
|
111
|
+
signer: addressSchema,
|
|
112
|
+
signature: signatureSchema
|
|
113
|
+
});
|
|
114
|
+
var timelineEventSchema = z.object({
|
|
115
|
+
key: hex32Schema,
|
|
116
|
+
kind: z.enum(timelineKinds),
|
|
117
|
+
at: z.string().datetime(),
|
|
118
|
+
actor: z.string().min(1),
|
|
119
|
+
ref: z.string().nullable(),
|
|
120
|
+
prevHash: hex32Schema,
|
|
121
|
+
hash: hex32Schema
|
|
122
|
+
});
|
|
123
|
+
var stopwatchSchema = packageVersionSchema.extend({
|
|
124
|
+
key: hex32Schema,
|
|
125
|
+
simulated: z.boolean(),
|
|
126
|
+
publishedAt: z.string().datetime(),
|
|
127
|
+
firstVerdictAt: z.string().datetime().nullable(),
|
|
128
|
+
sealedAt: z.string().datetime().nullable(),
|
|
129
|
+
firstBlockAt: z.string().datetime().nullable()
|
|
130
|
+
});
|
|
131
|
+
var guardBlockSchema = packageVersionSchema.extend({
|
|
132
|
+
key: hex32Schema,
|
|
133
|
+
lockfilePath: z.string(),
|
|
134
|
+
alreadyInstalled: z.boolean()
|
|
135
|
+
});
|
|
136
|
+
var guardSuspectSchema = packageVersionSchema.extend({
|
|
137
|
+
key: hex32Schema,
|
|
138
|
+
lockfilePath: z.string(),
|
|
139
|
+
votes: z.number().int().nonnegative(),
|
|
140
|
+
suspectUntil: z.string().datetime().nullable(),
|
|
141
|
+
allowed: z.boolean()
|
|
142
|
+
});
|
|
143
|
+
var guardReportSchema = z.object({
|
|
144
|
+
product: z.literal(productName),
|
|
145
|
+
service: z.string().nullable(),
|
|
146
|
+
lockfile: z.string(),
|
|
147
|
+
checkedAt: z.string().datetime(),
|
|
148
|
+
listSource: z.enum(listSources),
|
|
149
|
+
fallback: z.object({ minAgeDays: z.number().int().positive(), placeholder: z.boolean() }).nullable(),
|
|
150
|
+
packagesChecked: z.number().int().nonnegative(),
|
|
151
|
+
blocked: z.array(guardBlockSchema),
|
|
152
|
+
suspect: z.array(guardSuspectSchema),
|
|
153
|
+
snapshotAgeSeconds: z.number().int().nonnegative().nullable(),
|
|
154
|
+
nonRegistrySources: z.array(
|
|
155
|
+
packageVersionSchema.extend({
|
|
156
|
+
resolved: z.string(),
|
|
157
|
+
kind: z.enum(["git", "github", "commit", "tarball", "file", "other"]),
|
|
158
|
+
lockfilePath: z.string()
|
|
159
|
+
})
|
|
160
|
+
),
|
|
161
|
+
hookFiles: z.array(z.object({ path: z.string(), tool: z.string() })),
|
|
162
|
+
tooYoung: z.array(packageVersionSchema.extend({ publishedAt: z.string().datetime().nullable() })),
|
|
163
|
+
rollback: z.array(z.object({ name: z.string(), from: z.string(), to: z.string().nullable() })),
|
|
164
|
+
advice: z.array(z.string()),
|
|
165
|
+
outcome: z.enum(guardOutcomes)
|
|
166
|
+
});
|
|
167
|
+
var signedGuardReportSchema = z.object({
|
|
168
|
+
report: guardReportSchema,
|
|
169
|
+
signer: addressSchema,
|
|
170
|
+
signature: signatureSchema
|
|
171
|
+
});
|
|
172
|
+
var reportAckSchema = z.object({
|
|
173
|
+
recorded: z.number().int().nonnegative()
|
|
174
|
+
});
|
|
175
|
+
var serviceStatusSchema = z.object({
|
|
176
|
+
service: z.string().min(1),
|
|
177
|
+
healthy: z.boolean(),
|
|
178
|
+
report: guardReportSchema,
|
|
179
|
+
updatedAt: z.string().datetime()
|
|
180
|
+
});
|
|
181
|
+
var apiErrorSchema = z.object({
|
|
182
|
+
error: z.string()
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ../shared/src/hashing.ts
|
|
186
|
+
import { encodeAbiParameters, keccak256, toBytes } from "viem";
|
|
187
|
+
function stableStringify(value) {
|
|
188
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
189
|
+
if (value !== null && typeof value === "object") {
|
|
190
|
+
const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
191
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
|
|
192
|
+
}
|
|
193
|
+
return JSON.stringify(value);
|
|
194
|
+
}
|
|
195
|
+
function hashJson(value) {
|
|
196
|
+
return keccak256(toBytes(stableStringify(value)));
|
|
197
|
+
}
|
|
198
|
+
function sealKeyOf(name, version) {
|
|
199
|
+
return keccak256(
|
|
200
|
+
encodeAbiParameters([{ type: "string" }, { type: "string" }, { type: "string" }], [ecosystem, name, version])
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
function snapshotDigestOf(snapshot) {
|
|
204
|
+
return hashJson(snapshot);
|
|
205
|
+
}
|
|
206
|
+
function guardReportDigestOf(report) {
|
|
207
|
+
return hashJson(report);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ../shared/src/chain.ts
|
|
211
|
+
import { defineChain } from "viem";
|
|
212
|
+
|
|
213
|
+
// ../shared/src/abis/sealRegistry.ts
|
|
214
|
+
var sealKeyInput = { name: "key", type: "bytes32" };
|
|
215
|
+
var sealRegistryAbi = [
|
|
216
|
+
{
|
|
217
|
+
type: "constructor",
|
|
218
|
+
stateMutability: "nonpayable",
|
|
219
|
+
inputs: [
|
|
220
|
+
{ name: "guardian", type: "address" },
|
|
221
|
+
{ name: "detectors", type: "address[]" },
|
|
222
|
+
{ name: "quorum_", type: "uint16" },
|
|
223
|
+
{ name: "disputeWindow_", type: "uint64" },
|
|
224
|
+
{ name: "voteLifetime_", type: "uint64" },
|
|
225
|
+
{ name: "maxSealsPerDay_", type: "uint16" }
|
|
226
|
+
]
|
|
227
|
+
},
|
|
228
|
+
{ type: "function", name: "DETECTOR_ROLE", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "bytes32" }] },
|
|
229
|
+
{ type: "function", name: "GUARDIAN_ROLE", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "bytes32" }] },
|
|
230
|
+
{ type: "function", name: "quorum", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint16" }] },
|
|
231
|
+
{ type: "function", name: "disputeWindow", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint64" }] },
|
|
232
|
+
{ type: "function", name: "voteLifetime", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint64" }] },
|
|
233
|
+
{ type: "function", name: "maxSealsPerDay", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint16" }] },
|
|
234
|
+
{
|
|
235
|
+
type: "function",
|
|
236
|
+
name: "keyOf",
|
|
237
|
+
stateMutability: "pure",
|
|
238
|
+
inputs: [
|
|
239
|
+
{ name: "ecosystem", type: "string" },
|
|
240
|
+
{ name: "name", type: "string" },
|
|
241
|
+
{ name: "version", type: "string" }
|
|
242
|
+
],
|
|
243
|
+
outputs: [{ name: "", type: "bytes32" }]
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
type: "function",
|
|
247
|
+
name: "submitVerdict",
|
|
248
|
+
stateMutability: "nonpayable",
|
|
249
|
+
inputs: [
|
|
250
|
+
{ name: "ecosystem", type: "string" },
|
|
251
|
+
{ name: "name", type: "string" },
|
|
252
|
+
{ name: "version", type: "string" },
|
|
253
|
+
{ name: "evidenceHash", type: "bytes32" }
|
|
254
|
+
],
|
|
255
|
+
outputs: [{ name: "key", type: "bytes32" }]
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
type: "function",
|
|
259
|
+
name: "promote",
|
|
260
|
+
stateMutability: "nonpayable",
|
|
261
|
+
inputs: [sealKeyInput, { name: "reasonHash", type: "bytes32" }],
|
|
262
|
+
outputs: []
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
type: "function",
|
|
266
|
+
name: "clear",
|
|
267
|
+
stateMutability: "nonpayable",
|
|
268
|
+
inputs: [sealKeyInput, { name: "reasonHash", type: "bytes32" }],
|
|
269
|
+
outputs: []
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
type: "function",
|
|
273
|
+
name: "unseal",
|
|
274
|
+
stateMutability: "nonpayable",
|
|
275
|
+
inputs: [sealKeyInput, { name: "reasonHash", type: "bytes32" }],
|
|
276
|
+
outputs: []
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
type: "function",
|
|
280
|
+
name: "reseal",
|
|
281
|
+
stateMutability: "nonpayable",
|
|
282
|
+
inputs: [sealKeyInput, { name: "reasonHash", type: "bytes32" }],
|
|
283
|
+
outputs: []
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
type: "function",
|
|
287
|
+
name: "getSeal",
|
|
288
|
+
stateMutability: "view",
|
|
289
|
+
inputs: [sealKeyInput],
|
|
290
|
+
outputs: [
|
|
291
|
+
{
|
|
292
|
+
name: "",
|
|
293
|
+
type: "tuple",
|
|
294
|
+
components: [
|
|
295
|
+
{ name: "status", type: "uint8" },
|
|
296
|
+
{ name: "votes", type: "uint16" },
|
|
297
|
+
{ name: "firstVoteAt", type: "uint64" },
|
|
298
|
+
{ name: "sealedAt", type: "uint64" },
|
|
299
|
+
{ name: "unsealedAt", type: "uint64" },
|
|
300
|
+
{ name: "clearedAt", type: "uint64" }
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
]
|
|
304
|
+
},
|
|
305
|
+
{ type: "function", name: "isSealed", stateMutability: "view", inputs: [sealKeyInput], outputs: [{ name: "", type: "bool" }] },
|
|
306
|
+
{
|
|
307
|
+
type: "function",
|
|
308
|
+
name: "hasVoted",
|
|
309
|
+
stateMutability: "view",
|
|
310
|
+
inputs: [sealKeyInput, { name: "detector", type: "address" }],
|
|
311
|
+
outputs: [{ name: "", type: "bool" }]
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
type: "function",
|
|
315
|
+
name: "canVote",
|
|
316
|
+
stateMutability: "view",
|
|
317
|
+
inputs: [sealKeyInput, { name: "detector", type: "address" }],
|
|
318
|
+
outputs: [{ name: "", type: "bool" }]
|
|
319
|
+
},
|
|
320
|
+
{ type: "function", name: "liveVotes", stateMutability: "view", inputs: [sealKeyInput], outputs: [{ name: "", type: "uint16" }] },
|
|
321
|
+
{ type: "function", name: "isSuspect", stateMutability: "view", inputs: [sealKeyInput], outputs: [{ name: "", type: "bool" }] },
|
|
322
|
+
{ type: "function", name: "flaggedKeyCount", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint256" }] },
|
|
323
|
+
{
|
|
324
|
+
type: "function",
|
|
325
|
+
name: "flaggedKeys",
|
|
326
|
+
stateMutability: "view",
|
|
327
|
+
inputs: [
|
|
328
|
+
{ name: "offset", type: "uint256" },
|
|
329
|
+
{ name: "limit", type: "uint256" }
|
|
330
|
+
],
|
|
331
|
+
outputs: [
|
|
332
|
+
{ name: "keys", type: "bytes32[]" },
|
|
333
|
+
{ name: "statuses", type: "uint8[]" },
|
|
334
|
+
{ name: "votes", type: "uint16[]" }
|
|
335
|
+
]
|
|
336
|
+
},
|
|
337
|
+
{ type: "function", name: "sealedKeyCount", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint256" }] },
|
|
338
|
+
{
|
|
339
|
+
type: "function",
|
|
340
|
+
name: "sealedKeys",
|
|
341
|
+
stateMutability: "view",
|
|
342
|
+
inputs: [
|
|
343
|
+
{ name: "offset", type: "uint256" },
|
|
344
|
+
{ name: "limit", type: "uint256" }
|
|
345
|
+
],
|
|
346
|
+
outputs: [
|
|
347
|
+
{ name: "keys", type: "bytes32[]" },
|
|
348
|
+
{ name: "statuses", type: "uint8[]" }
|
|
349
|
+
]
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
type: "event",
|
|
353
|
+
name: "VerdictSubmitted",
|
|
354
|
+
anonymous: false,
|
|
355
|
+
inputs: [
|
|
356
|
+
{ name: "key", type: "bytes32", indexed: true },
|
|
357
|
+
{ name: "detector", type: "address", indexed: true },
|
|
358
|
+
{ name: "evidenceHash", type: "bytes32", indexed: false },
|
|
359
|
+
{ name: "votes", type: "uint16", indexed: false },
|
|
360
|
+
{ name: "at", type: "uint64", indexed: false }
|
|
361
|
+
]
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
type: "event",
|
|
365
|
+
name: "Sealed",
|
|
366
|
+
anonymous: false,
|
|
367
|
+
inputs: [
|
|
368
|
+
{ name: "key", type: "bytes32", indexed: true },
|
|
369
|
+
{ name: "ecosystem", type: "string", indexed: false },
|
|
370
|
+
{ name: "name", type: "string", indexed: false },
|
|
371
|
+
{ name: "version", type: "string", indexed: false },
|
|
372
|
+
{ name: "votes", type: "uint16", indexed: false },
|
|
373
|
+
{ name: "at", type: "uint64", indexed: false }
|
|
374
|
+
]
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
type: "event",
|
|
378
|
+
name: "Unsealed",
|
|
379
|
+
anonymous: false,
|
|
380
|
+
inputs: [
|
|
381
|
+
{ name: "key", type: "bytes32", indexed: true },
|
|
382
|
+
{ name: "guardian", type: "address", indexed: true },
|
|
383
|
+
{ name: "reasonHash", type: "bytes32", indexed: false },
|
|
384
|
+
{ name: "at", type: "uint64", indexed: false }
|
|
385
|
+
]
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
type: "event",
|
|
389
|
+
name: "Resealed",
|
|
390
|
+
anonymous: false,
|
|
391
|
+
inputs: [
|
|
392
|
+
{ name: "key", type: "bytes32", indexed: true },
|
|
393
|
+
{ name: "guardian", type: "address", indexed: true },
|
|
394
|
+
{ name: "reasonHash", type: "bytes32", indexed: false },
|
|
395
|
+
{ name: "at", type: "uint64", indexed: false }
|
|
396
|
+
]
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
type: "event",
|
|
400
|
+
name: "Promoted",
|
|
401
|
+
anonymous: false,
|
|
402
|
+
inputs: [
|
|
403
|
+
{ name: "key", type: "bytes32", indexed: true },
|
|
404
|
+
{ name: "guardian", type: "address", indexed: true },
|
|
405
|
+
{ name: "reasonHash", type: "bytes32", indexed: false },
|
|
406
|
+
{ name: "at", type: "uint64", indexed: false }
|
|
407
|
+
]
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
type: "event",
|
|
411
|
+
name: "Cleared",
|
|
412
|
+
anonymous: false,
|
|
413
|
+
inputs: [
|
|
414
|
+
{ name: "key", type: "bytes32", indexed: true },
|
|
415
|
+
{ name: "guardian", type: "address", indexed: true },
|
|
416
|
+
{ name: "reasonHash", type: "bytes32", indexed: false },
|
|
417
|
+
{ name: "at", type: "uint64", indexed: false }
|
|
418
|
+
]
|
|
419
|
+
},
|
|
420
|
+
{ type: "error", name: "InvalidConfig", inputs: [] },
|
|
421
|
+
{ type: "error", name: "EmptyEvidence", inputs: [] },
|
|
422
|
+
{ type: "error", name: "InvalidEcosystem", inputs: [] },
|
|
423
|
+
{ type: "error", name: "RolesFrozen", inputs: [] },
|
|
424
|
+
{
|
|
425
|
+
type: "error",
|
|
426
|
+
name: "DuplicateVote",
|
|
427
|
+
inputs: [sealKeyInput, { name: "detector", type: "address" }]
|
|
428
|
+
},
|
|
429
|
+
{ type: "error", name: "AlreadySealed", inputs: [sealKeyInput] },
|
|
430
|
+
{ type: "error", name: "KeyUnsealed", inputs: [sealKeyInput] },
|
|
431
|
+
{ type: "error", name: "NotSealed", inputs: [sealKeyInput] },
|
|
432
|
+
{ type: "error", name: "NotUnsealed", inputs: [sealKeyInput] },
|
|
433
|
+
{ type: "error", name: "KeyCleared", inputs: [sealKeyInput] },
|
|
434
|
+
{ type: "error", name: "NotSuspect", inputs: [sealKeyInput] },
|
|
435
|
+
{ type: "error", name: "DisputeWindowOpen", inputs: [{ name: "opensAt", type: "uint64" }] },
|
|
436
|
+
{ type: "error", name: "SealRateLimited", inputs: [{ name: "day", type: "uint64" }] },
|
|
437
|
+
{
|
|
438
|
+
type: "error",
|
|
439
|
+
name: "AccessControlUnauthorizedAccount",
|
|
440
|
+
inputs: [
|
|
441
|
+
{ name: "account", type: "address" },
|
|
442
|
+
{ name: "neededRole", type: "bytes32" }
|
|
443
|
+
]
|
|
444
|
+
}
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
// src/check.ts
|
|
448
|
+
import { join as join3 } from "node:path";
|
|
449
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
450
|
+
|
|
451
|
+
// src/lockfile.ts
|
|
452
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
453
|
+
import { basename, dirname, join } from "node:path";
|
|
454
|
+
var lockfileNames = ["npm-shrinkwrap.json", "package-lock.json"];
|
|
455
|
+
var hiddenLockfileName = ".package-lock.json";
|
|
456
|
+
var maxLockfileBytes = 64 * 1024 * 1024;
|
|
457
|
+
var defaultRegistryUrl = "https://registry.npmjs.org";
|
|
458
|
+
var gitPattern = /^git(\+[a-z]+)?:/;
|
|
459
|
+
var githubPattern = /^(github:|https?:\/\/codeload\.github\.com\/)|^[\w.]+\/[\w.]+(#.*)?$/;
|
|
460
|
+
var semverPattern = /^\d+\.\d+\.\d+([+-][\w.]+)*$/;
|
|
461
|
+
function originOf(url) {
|
|
462
|
+
return URL.canParse(url) ? new URL(url).origin : null;
|
|
463
|
+
}
|
|
464
|
+
function sourceKindOf(entry, registryUrl) {
|
|
465
|
+
const source = entry.resolved ?? entry.version;
|
|
466
|
+
if (gitPattern.test(source) || githubPattern.test(source)) {
|
|
467
|
+
if (source.includes("#")) return "commit";
|
|
468
|
+
return gitPattern.test(source) ? "git" : "github";
|
|
469
|
+
}
|
|
470
|
+
if (source.startsWith("file:")) return "file";
|
|
471
|
+
if (/^https?:\/\//.test(source)) return originOf(source) === originOf(registryUrl) ? null : "tarball";
|
|
472
|
+
return semverPattern.test(source) ? null : "other";
|
|
473
|
+
}
|
|
474
|
+
function nonRegistrySourcesOf(entries, lockfilePath, registryUrl) {
|
|
475
|
+
return entries.flatMap((entry) => {
|
|
476
|
+
const kind = sourceKindOf(entry, registryUrl);
|
|
477
|
+
return kind ? [{ name: entry.name, version: entry.version, resolved: entry.resolved ?? entry.version, kind, lockfilePath }] : [];
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
function resolveLockfilePath(target) {
|
|
481
|
+
if (!existsSync(target) || !statSync(target).isDirectory()) return target;
|
|
482
|
+
const found = lockfileNames.map((name) => join(target, name)).find((path) => existsSync(path));
|
|
483
|
+
return found ?? join(target, lockfileNames[1]);
|
|
484
|
+
}
|
|
485
|
+
function directoryOf(target) {
|
|
486
|
+
return existsSync(target) && statSync(target).isDirectory() ? target : dirname(target);
|
|
487
|
+
}
|
|
488
|
+
function readJson(path) {
|
|
489
|
+
try {
|
|
490
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (error instanceof SyntaxError || error.code === "ENOENT") return null;
|
|
493
|
+
throw new Error(`cannot read ${path}: ${error.message}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
var tarballPathPattern = /\/((?:@[^/]+\/)?[^/@][^/]*)\/-\/([^/]+)\.tgz$/;
|
|
497
|
+
function tarballIdentityOf(resolved) {
|
|
498
|
+
if (!resolved || !URL.canParse(resolved)) return null;
|
|
499
|
+
const match = tarballPathPattern.exec(decodeURIComponent(new URL(resolved).pathname));
|
|
500
|
+
if (!match) return null;
|
|
501
|
+
const name = match[1];
|
|
502
|
+
const file = match[2];
|
|
503
|
+
const unscoped = name.slice(name.lastIndexOf("/") + 1);
|
|
504
|
+
if (!file.startsWith(`${unscoped}-`)) return null;
|
|
505
|
+
return { name, version: file.slice(unscoped.length + 1) };
|
|
506
|
+
}
|
|
507
|
+
function entriesOf(packages) {
|
|
508
|
+
return Object.entries(packages).flatMap(([path, entry]) => {
|
|
509
|
+
if (!path.includes("node_modules/") || entry.link === true) return [];
|
|
510
|
+
if (typeof entry.version !== "string") return [];
|
|
511
|
+
const declared = typeof entry.name === "string" ? entry.name : path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
|
|
512
|
+
const resolved = typeof entry.resolved === "string" ? entry.resolved : null;
|
|
513
|
+
const fetched = tarballIdentityOf(resolved);
|
|
514
|
+
return [{ name: fetched?.name ?? declared, version: fetched?.version ?? entry.version, path, resolved }];
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
function readLockfile(path, acceptedNames = lockfileNames) {
|
|
518
|
+
if (!existsSync(path)) return { ok: false, path, reason: `no lockfile at ${path}` };
|
|
519
|
+
if (!acceptedNames.includes(basename(path))) {
|
|
520
|
+
return { ok: false, path, reason: `${path} is not ${acceptedNames.join(" or ")}, the only formats supported here` };
|
|
521
|
+
}
|
|
522
|
+
if (statSync(path).size > maxLockfileBytes) return { ok: false, path, reason: `${path} is larger than ${maxLockfileBytes} bytes` };
|
|
523
|
+
const parsed = readJson(path);
|
|
524
|
+
if (parsed === null || typeof parsed !== "object") return { ok: false, path, reason: `${path} is not valid JSON` };
|
|
525
|
+
if (parsed.lockfileVersion !== 2 && parsed.lockfileVersion !== 3) {
|
|
526
|
+
return { ok: false, path, reason: `${path} has lockfileVersion ${String(parsed.lockfileVersion)}, only 2 and 3 are supported` };
|
|
527
|
+
}
|
|
528
|
+
if (parsed.packages === null || typeof parsed.packages !== "object") {
|
|
529
|
+
return { ok: false, path, reason: `${path} has no packages section` };
|
|
530
|
+
}
|
|
531
|
+
return { ok: true, path, entries: entriesOf(parsed.packages) };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/registry.ts
|
|
535
|
+
function packumentReader(registryUrl, fetchFn, warn, timeoutMs) {
|
|
536
|
+
const cache = /* @__PURE__ */ new Map();
|
|
537
|
+
let warned = false;
|
|
538
|
+
const load = async (name) => {
|
|
539
|
+
if (!registryUrl) return null;
|
|
540
|
+
const url = `${registryUrl.replace(/\/$/, "")}/${name.replace("/", "%2f")}`;
|
|
541
|
+
return fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) }).then((response) => {
|
|
542
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
543
|
+
return response.json();
|
|
544
|
+
}).catch((error) => {
|
|
545
|
+
if (!warned) warn(`registry metadata unavailable, first failure: ${error.message}`);
|
|
546
|
+
warned = true;
|
|
547
|
+
return null;
|
|
548
|
+
});
|
|
549
|
+
};
|
|
550
|
+
return (name) => {
|
|
551
|
+
const known = cache.get(name) ?? load(name);
|
|
552
|
+
cache.set(name, known);
|
|
553
|
+
return known;
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function publishedAtOf(packument, version) {
|
|
557
|
+
const raw = packument?.time?.[version];
|
|
558
|
+
const time = raw ? Date.parse(raw) : Number.NaN;
|
|
559
|
+
return Number.isNaN(time) ? null : new Date(time);
|
|
560
|
+
}
|
|
561
|
+
function isOlderThan(publishedAt, days, now) {
|
|
562
|
+
return publishedAt !== null && now.getTime() - publishedAt.getTime() >= days * 864e5;
|
|
563
|
+
}
|
|
564
|
+
function coreOf(version) {
|
|
565
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
566
|
+
return match ? match.slice(1).map(Number) : [];
|
|
567
|
+
}
|
|
568
|
+
function compareCores(left, right) {
|
|
569
|
+
const a = coreOf(left);
|
|
570
|
+
const b = coreOf(right);
|
|
571
|
+
const index = [0, 1, 2].find((position) => a[position] !== b[position]);
|
|
572
|
+
return index === void 0 ? 0 : (a[index] ?? -1) - (b[index] ?? -1);
|
|
573
|
+
}
|
|
574
|
+
function isRelease(version) {
|
|
575
|
+
return /^\d+\.\d+\.\d+(\+.*)?$/.test(version);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/rollback.ts
|
|
579
|
+
async function proposeOne(target, limits, readPackument) {
|
|
580
|
+
const packument = await readPackument(target.name);
|
|
581
|
+
const versions = Object.keys(packument?.versions ?? packument?.time ?? {});
|
|
582
|
+
const isSafe = (version) => !limits.sealedKeys.has(sealKeyOf(target.name, version)) && (limits.minAgeDays === 0 || isOlderThan(publishedAtOf(packument, version), limits.minAgeDays, limits.now));
|
|
583
|
+
const candidates = versions.filter((version) => isRelease(version) && compareCores(version, target.version) < 0 && isSafe(version));
|
|
584
|
+
return { name: target.name, from: target.version, to: candidates.sort(compareCores).at(-1) ?? null };
|
|
585
|
+
}
|
|
586
|
+
function proposeRollbacks(targets, limits, readPackument) {
|
|
587
|
+
const unique = [...new Map(targets.map((target) => [`${target.name}@${target.version}`, target])).values()];
|
|
588
|
+
return Promise.all(unique.map((target) => proposeOne(target, limits, readPackument)));
|
|
589
|
+
}
|
|
590
|
+
function overridesOf(rollback) {
|
|
591
|
+
const proposals = rollback.flatMap(({ name, to }) => to === null ? [] : [[name, to]]);
|
|
592
|
+
return { overrides: Object.fromEntries(proposals) };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/sealedList.ts
|
|
596
|
+
import { existsSync as existsSync2, mkdirSync, writeFileSync } from "node:fs";
|
|
597
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
598
|
+
import { createPublicClient, http, verifyMessage } from "viem";
|
|
599
|
+
var noneStatus = 0;
|
|
600
|
+
var sealedStatus = 1;
|
|
601
|
+
var unsealedStatus = 2;
|
|
602
|
+
var pageSize = 500n;
|
|
603
|
+
var cacheFileName = "sealedSnapshot.json";
|
|
604
|
+
var knownFileName = "knownSealed.json";
|
|
605
|
+
var clockSkewSeconds = 60;
|
|
606
|
+
async function readSuspects(client, registry) {
|
|
607
|
+
const total = await client.readContract({ address: registry, abi: sealRegistryAbi, functionName: "flaggedKeyCount" });
|
|
608
|
+
const suspects = /* @__PURE__ */ new Map();
|
|
609
|
+
for (let offset = 0n; offset < total; offset += pageSize) {
|
|
610
|
+
const [page, statuses, votes] = await client.readContract({
|
|
611
|
+
address: registry,
|
|
612
|
+
abi: sealRegistryAbi,
|
|
613
|
+
functionName: "flaggedKeys",
|
|
614
|
+
args: [offset, pageSize]
|
|
615
|
+
});
|
|
616
|
+
page.forEach((key, index) => {
|
|
617
|
+
const live = votes[index] ?? 0;
|
|
618
|
+
if (statuses[index] === noneStatus && live > 0) suspects.set(key.toLowerCase(), { votes: live, suspectUntil: null });
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return suspects;
|
|
622
|
+
}
|
|
623
|
+
async function readChain(settings, registry, chainId) {
|
|
624
|
+
const transport = settings.transport ?? http(settings.rpcUrl ?? void 0, { retryCount: 0, timeout: settings.httpTimeoutMs });
|
|
625
|
+
const client = createPublicClient({ transport });
|
|
626
|
+
const remoteChainId = await client.getChainId();
|
|
627
|
+
if (remoteChainId !== chainId) throw new Error(`RPC reports chain ${remoteChainId}, expected ${chainId}`);
|
|
628
|
+
const total = await client.readContract({ address: registry, abi: sealRegistryAbi, functionName: "sealedKeyCount" });
|
|
629
|
+
const sealed = /* @__PURE__ */ new Set();
|
|
630
|
+
const unsealed = /* @__PURE__ */ new Set();
|
|
631
|
+
for (let offset = 0n; offset < total; offset += pageSize) {
|
|
632
|
+
const [page, statuses] = await client.readContract({
|
|
633
|
+
address: registry,
|
|
634
|
+
abi: sealRegistryAbi,
|
|
635
|
+
functionName: "sealedKeys",
|
|
636
|
+
args: [offset, pageSize]
|
|
637
|
+
});
|
|
638
|
+
page.forEach((key, index) => {
|
|
639
|
+
if (statuses[index] === sealedStatus) sealed.add(key.toLowerCase());
|
|
640
|
+
if (statuses[index] === unsealedStatus) unsealed.add(key.toLowerCase());
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return { client, sealed, unsealed, suspects: await readSuspects(client, registry) };
|
|
644
|
+
}
|
|
645
|
+
async function hasUnsealEvent(read, registry, key, fromBlock) {
|
|
646
|
+
if (!read.unsealed.has(key)) return false;
|
|
647
|
+
const logs = await read.client.getContractEvents({ address: registry, abi: sealRegistryAbi, eventName: "Unsealed", args: { key }, fromBlock: BigInt(fromBlock) }).catch(() => []);
|
|
648
|
+
return logs.length > 0;
|
|
649
|
+
}
|
|
650
|
+
async function chainList(settings, known) {
|
|
651
|
+
const { registryAddress, chainId } = settings;
|
|
652
|
+
if (!registryAddress || chainId === null || !(settings.transport || settings.rpcUrl)) return null;
|
|
653
|
+
const read = await readChain(settings, registryAddress, chainId).catch((error) => {
|
|
654
|
+
settings.warn(`chain unavailable: ${error.message.split("\n")[0]}`);
|
|
655
|
+
return null;
|
|
656
|
+
});
|
|
657
|
+
if (!read) return null;
|
|
658
|
+
const dropped = [...known].filter((key) => !read.sealed.has(key));
|
|
659
|
+
const explained = await Promise.all(dropped.map((key) => hasUnsealEvent(read, registryAddress, key, settings.registryStartBlock ?? 0)));
|
|
660
|
+
const unexplained = dropped.filter((_, index) => !explained[index]);
|
|
661
|
+
if (unexplained.length) {
|
|
662
|
+
settings.warn(
|
|
663
|
+
`chain list rejected: it drops ${unexplained.length} previously sealed version(s) with no matching Unsealed event (${unexplained.join(", ")}), so the guard uses the signed snapshot and keeps them blocked`
|
|
664
|
+
);
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
return { source: "chain", keys: read.sealed, suspects: read.suspects, snapshotAgeSeconds: null };
|
|
668
|
+
}
|
|
669
|
+
async function snapshotProblem(signed, settings) {
|
|
670
|
+
const { snapshot } = signed;
|
|
671
|
+
if (!settings.snapshotSigner || settings.snapshotMaxAgeSeconds === null) return "snapshot signer or max age not configured";
|
|
672
|
+
if (signed.signer.toLowerCase() !== settings.snapshotSigner.toLowerCase()) return "snapshot signer is not GUARD_SNAPSHOT_SIGNER";
|
|
673
|
+
const digest = snapshotDigestOf(snapshot);
|
|
674
|
+
const valid = await verifyMessage({ address: settings.snapshotSigner, message: { raw: digest }, signature: signed.signature });
|
|
675
|
+
if (!valid) return "snapshot signature does not verify";
|
|
676
|
+
if (snapshot.chainId !== settings.chainId) return `snapshot chainId ${snapshot.chainId} does not match CHAIN_ID`;
|
|
677
|
+
if (snapshot.registry.toLowerCase() !== settings.registryAddress?.toLowerCase()) return "snapshot registry does not match REGISTRY_ADDRESS";
|
|
678
|
+
const generatedAt = Date.parse(snapshot.generatedAt);
|
|
679
|
+
if (generatedAt > settings.now().getTime() + clockSkewSeconds * 1e3) return "snapshot generatedAt is in the future";
|
|
680
|
+
if (snapshotAgeOf(signed, settings) > settings.snapshotMaxAgeSeconds) return "snapshot is older than GUARD_SNAPSHOT_MAX_AGE_SECONDS";
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
function snapshotAgeOf(signed, settings) {
|
|
684
|
+
return Math.max(0, Math.floor((settings.now().getTime() - Date.parse(signed.snapshot.generatedAt)) / 1e3));
|
|
685
|
+
}
|
|
686
|
+
async function acceptSnapshot(raw, origin, settings) {
|
|
687
|
+
const parsed = signedSnapshotSchema.safeParse(raw);
|
|
688
|
+
if (!parsed.success) {
|
|
689
|
+
settings.warn(`${origin} snapshot rejected: malformed`);
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
const problem = await snapshotProblem(parsed.data, settings).catch(() => "snapshot signature is malformed");
|
|
693
|
+
if (problem) settings.warn(`${origin} snapshot rejected: ${problem}`);
|
|
694
|
+
return problem ? null : parsed.data;
|
|
695
|
+
}
|
|
696
|
+
async function loadSnapshot(settings) {
|
|
697
|
+
const cache = settings.cacheDir ? join2(settings.cacheDir, cacheFileName) : null;
|
|
698
|
+
if (settings.snapshotUrl) {
|
|
699
|
+
const fetched = await settings.fetchFn(settings.snapshotUrl, { signal: AbortSignal.timeout(settings.httpTimeoutMs) }).then((response) => {
|
|
700
|
+
if (!response.ok) throw new Error(`snapshot request answered ${response.status}`);
|
|
701
|
+
return response.json();
|
|
702
|
+
}).catch((error) => {
|
|
703
|
+
settings.warn(`snapshot fetch failed: ${error.message}`);
|
|
704
|
+
return null;
|
|
705
|
+
});
|
|
706
|
+
const accepted = fetched === null ? null : await acceptSnapshot(fetched, "fetched", settings);
|
|
707
|
+
if (accepted && cache) {
|
|
708
|
+
mkdirSync(dirname2(cache), { recursive: true });
|
|
709
|
+
writeFileSync(cache, JSON.stringify(accepted));
|
|
710
|
+
}
|
|
711
|
+
if (accepted) return accepted;
|
|
712
|
+
}
|
|
713
|
+
if (!cache || !existsSync2(cache)) return null;
|
|
714
|
+
return acceptSnapshot(readJson(cache), "cached", settings);
|
|
715
|
+
}
|
|
716
|
+
function knownPath(settings) {
|
|
717
|
+
return settings.cacheDir ? join2(settings.cacheDir, knownFileName) : null;
|
|
718
|
+
}
|
|
719
|
+
function readKnown(settings) {
|
|
720
|
+
const path = knownPath(settings);
|
|
721
|
+
const stored = path ? readJson(path) : null;
|
|
722
|
+
const sameRegistry = stored?.chainId === settings.chainId && stored?.registry?.toLowerCase() === settings.registryAddress?.toLowerCase();
|
|
723
|
+
return new Set(sameRegistry ? stored?.keys ?? [] : []);
|
|
724
|
+
}
|
|
725
|
+
function writeKnown(settings, keys) {
|
|
726
|
+
const path = knownPath(settings);
|
|
727
|
+
if (!path) return;
|
|
728
|
+
mkdirSync(dirname2(path), { recursive: true });
|
|
729
|
+
writeFileSync(path, JSON.stringify({ chainId: settings.chainId, registry: settings.registryAddress, keys: [...keys] }));
|
|
730
|
+
}
|
|
731
|
+
async function snapshotList(settings, known) {
|
|
732
|
+
const signed = await loadSnapshot(settings);
|
|
733
|
+
const entries = signed?.snapshot.entries ?? [];
|
|
734
|
+
const keys = new Set(entries.filter((entry) => entry.status === "Sealed").map((entry) => entry.key));
|
|
735
|
+
const unsealed = new Set(entries.filter((entry) => entry.status === "Unsealed").map((entry) => entry.key));
|
|
736
|
+
const suspects = new Map(
|
|
737
|
+
entries.filter((entry) => entry.status === "Suspect").map((entry) => [entry.key, { votes: entry.votes, suspectUntil: entry.suspectUntil }])
|
|
738
|
+
);
|
|
739
|
+
const kept = [...known].filter((key) => !keys.has(key) && !unsealed.has(key));
|
|
740
|
+
if (kept.length) settings.warn(`${kept.length} previously sealed version(s) missing from the available list stay blocked: ${kept.join(", ")}`);
|
|
741
|
+
kept.forEach((key) => keys.add(key));
|
|
742
|
+
if (!signed) return { source: "none", keys, suspects, snapshotAgeSeconds: null };
|
|
743
|
+
return { source: "snapshot", keys, suspects, snapshotAgeSeconds: snapshotAgeOf(signed, settings) };
|
|
744
|
+
}
|
|
745
|
+
async function loadSealedList(settings) {
|
|
746
|
+
const known = readKnown(settings);
|
|
747
|
+
const list = await chainList(settings, known) ?? await snapshotList(settings, known);
|
|
748
|
+
writeKnown(settings, list.keys);
|
|
749
|
+
return list;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/check.ts
|
|
753
|
+
var placeholderFallbackMinAgeDays = 3;
|
|
754
|
+
var exitCodes = { allowed: 0, blocked: 1, unavailable: 2 };
|
|
755
|
+
var npmGitSetting = { name: "allow-git", minimumVersion: "11.10.0" };
|
|
756
|
+
var installedAdvice = [
|
|
757
|
+
"First remove persistence: background daemons or services the package installed, and IDE or coding agent hooks, extensions and config it added. Some payloads destroy files when a stolen token is revoked, so do this before revoking anything.",
|
|
758
|
+
"Then rotate every credential that was reachable when that version was installed, including npm tokens, cloud keys, CI secrets and SSH keys.",
|
|
759
|
+
"A runtime stop does not undo theft that happened at install time."
|
|
760
|
+
];
|
|
761
|
+
function fallbackOf(settings) {
|
|
762
|
+
if (settings.fallbackMinAgeDays !== null) return { minAgeDays: settings.fallbackMinAgeDays, placeholder: false };
|
|
763
|
+
settings.warn(
|
|
764
|
+
`GUARD_FALLBACK_MIN_AGE_DAYS is unset: the age fallback is using the placeholder of ${placeholderFallbackMinAgeDays} days. The team sets N in docs/decisions.md.`
|
|
765
|
+
);
|
|
766
|
+
return { minAgeDays: placeholderFallbackMinAgeDays, placeholder: true };
|
|
767
|
+
}
|
|
768
|
+
function blocksOf(entries, list, lockfilePath, assumeInstalled) {
|
|
769
|
+
return entries.flatMap((entry) => {
|
|
770
|
+
const key = sealKeyOf(entry.name, entry.version);
|
|
771
|
+
if (!list.keys.has(key)) return [];
|
|
772
|
+
const installed = readJson(join3(directoryOf(lockfilePath), entry.path, "package.json"));
|
|
773
|
+
const alreadyInstalled = assumeInstalled || installed?.name === entry.name && installed.version === entry.version;
|
|
774
|
+
return [{ name: entry.name, version: entry.version, key, lockfilePath, alreadyInstalled }];
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
function suspectsOf(entries, list, lockfilePath, settings) {
|
|
778
|
+
return entries.flatMap((entry) => {
|
|
779
|
+
const key = sealKeyOf(entry.name, entry.version);
|
|
780
|
+
const info = list.suspects.get(key);
|
|
781
|
+
if (!info || list.keys.has(key)) return [];
|
|
782
|
+
const allowed = !settings.suspectBlocks || settings.allowSuspect.includes(`${entry.name}@${entry.version}`);
|
|
783
|
+
return [{ name: entry.name, version: entry.version, key, lockfilePath, votes: info.votes, suspectUntil: info.suspectUntil, allowed }];
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
function suspectAdvice(suspects) {
|
|
787
|
+
const unique = [...new Map(suspects.map((suspect) => [suspect.key, suspect])).values()];
|
|
788
|
+
return unique.map(({ name, version, votes, allowed }) => {
|
|
789
|
+
const flagged = `${name}@${version} is Suspect: ${votes} detector${votes === 1 ? "" : "s"} flagged it and quorum is not reached yet.`;
|
|
790
|
+
return allowed ? `Warning: ${flagged} It does not block this run.` : `${flagged} Review the evidence on the dashboard, wait for the guardian, or allow this one version with --allow-suspect ${name}@${version}.`;
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
async function findTooYoung(entries, days, readPackument, now) {
|
|
794
|
+
const unique = [...new Map(entries.map((entry) => [`${entry.name}@${entry.version}`, entry])).values()];
|
|
795
|
+
const dated = await Promise.all(
|
|
796
|
+
unique.map(async (entry) => ({ entry, publishedAt: publishedAtOf(await readPackument(entry.name), entry.version) }))
|
|
797
|
+
);
|
|
798
|
+
const young = dated.filter(({ publishedAt }) => !isOlderThan(publishedAt, days, now));
|
|
799
|
+
const tooYoung = young.map(({ entry, publishedAt }) => ({
|
|
800
|
+
name: entry.name,
|
|
801
|
+
version: entry.version,
|
|
802
|
+
publishedAt: publishedAt?.toISOString() ?? null
|
|
803
|
+
}));
|
|
804
|
+
const unreadable = young.filter(({ publishedAt }) => publishedAt === null).map(({ entry }) => `${entry.name}@${entry.version}`);
|
|
805
|
+
return { tooYoung, unreadable };
|
|
806
|
+
}
|
|
807
|
+
function nonRegistryVerdict(sources, allow) {
|
|
808
|
+
if (!sources.length) return { blocks: false, advice: [] };
|
|
809
|
+
const listed = [...new Set(sources.map(({ name, version, kind }) => `${name}@${version} (${kind})`))].join(", ");
|
|
810
|
+
const effect = allow ? "were allowed by GUARD_ALLOW_NON_REGISTRY_SOURCES" : "block the outcome";
|
|
811
|
+
return {
|
|
812
|
+
blocks: !allow,
|
|
813
|
+
advice: [
|
|
814
|
+
`Warning: ${listed} come from a source other than the npm registry and ${effect}. Such sources can run prepare scripts even with scripts disabled and bypass the Sealed list.`
|
|
815
|
+
]
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
function adviceFor(blocked) {
|
|
819
|
+
return blocked.some((block) => block.alreadyInstalled) ? installedAdvice : [];
|
|
820
|
+
}
|
|
821
|
+
async function checkLockfile(settings) {
|
|
822
|
+
const now = settings.now();
|
|
823
|
+
const path = resolveLockfilePath(settings.lockfile);
|
|
824
|
+
const read = readLockfile(path);
|
|
825
|
+
const report = {
|
|
826
|
+
product: productName,
|
|
827
|
+
service: settings.service,
|
|
828
|
+
lockfile: path,
|
|
829
|
+
checkedAt: now.toISOString(),
|
|
830
|
+
listSource: "none",
|
|
831
|
+
snapshotAgeSeconds: null,
|
|
832
|
+
fallback: null,
|
|
833
|
+
packagesChecked: 0,
|
|
834
|
+
blocked: [],
|
|
835
|
+
suspect: [],
|
|
836
|
+
nonRegistrySources: [],
|
|
837
|
+
hookFiles: [],
|
|
838
|
+
tooYoung: [],
|
|
839
|
+
rollback: [],
|
|
840
|
+
advice: [],
|
|
841
|
+
outcome: "unavailable"
|
|
842
|
+
};
|
|
843
|
+
if (!read.ok) return { ...report, advice: [read.reason] };
|
|
844
|
+
const list = await loadSealedList(settings);
|
|
845
|
+
const fallback = list.source === "none" ? fallbackOf(settings) : null;
|
|
846
|
+
const ageLimit = Math.max(fallback?.minAgeDays ?? 0, settings.minAgeDays);
|
|
847
|
+
const readPackument = packumentReader(settings.registryUrl, settings.fetchFn, settings.warn, settings.httpTimeoutMs);
|
|
848
|
+
const blocked = blocksOf(read.entries, list, path, settings.assumeInstalled);
|
|
849
|
+
const suspect = suspectsOf(read.entries, list, path, settings);
|
|
850
|
+
const heldSuspects = suspect.filter((entry) => !entry.allowed);
|
|
851
|
+
const { tooYoung, unreadable } = ageLimit > 0 ? await findTooYoung(read.entries, ageLimit, readPackument, now) : { tooYoung: [], unreadable: [] };
|
|
852
|
+
const limits = { sealedKeys: /* @__PURE__ */ new Set([...list.keys, ...list.suspects.keys()]), minAgeDays: ageLimit, now };
|
|
853
|
+
const rollback = await proposeRollbacks([...blocked, ...heldSuspects, ...tooYoung], limits, readPackument);
|
|
854
|
+
const sources = nonRegistrySourcesOf(read.entries, path, settings.registryUrl ?? defaultRegistryUrl);
|
|
855
|
+
const git = nonRegistryVerdict(sources, settings.allowNonRegistrySources);
|
|
856
|
+
const unreadableAdvice = unreadable.length ? [`Publish time could not be read for ${unreadable.join(", ")}, so these count as too young.`] : [];
|
|
857
|
+
const fallbackAdvice = fallback ? [
|
|
858
|
+
`Warning: the Sealed list was not checked because neither the chain nor a valid signed snapshot was available. Versions younger than ${fallback.minAgeDays} days were blocked by the age fallback.`,
|
|
859
|
+
...fallback.placeholder ? [`N is the placeholder of ${fallback.minAgeDays} days until the team sets GUARD_FALLBACK_MIN_AGE_DAYS.`] : []
|
|
860
|
+
] : [];
|
|
861
|
+
return {
|
|
862
|
+
...report,
|
|
863
|
+
listSource: list.source,
|
|
864
|
+
snapshotAgeSeconds: list.snapshotAgeSeconds,
|
|
865
|
+
fallback,
|
|
866
|
+
packagesChecked: read.entries.length,
|
|
867
|
+
blocked,
|
|
868
|
+
suspect,
|
|
869
|
+
nonRegistrySources: sources,
|
|
870
|
+
tooYoung,
|
|
871
|
+
rollback,
|
|
872
|
+
advice: [...fallbackAdvice, ...git.advice, ...unreadableAdvice, ...suspectAdvice(suspect), ...adviceFor(blocked)],
|
|
873
|
+
outcome: blocked.length || heldSuspects.length || tooYoung.length || git.blocks ? "blocked" : "allowed"
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
async function sendReport(report, settings) {
|
|
877
|
+
if (!settings.reportUrl || !settings.reporterPrivateKey) return false;
|
|
878
|
+
const account = privateKeyToAccount(settings.reporterPrivateKey);
|
|
879
|
+
const signature = await account.signMessage({ message: { raw: guardReportDigestOf(report) } });
|
|
880
|
+
const body = JSON.stringify({ report, signer: account.address, signature });
|
|
881
|
+
const response = await settings.fetchFn(settings.reportUrl, { method: "POST", headers: { "content-type": "application/json" }, body, signal: AbortSignal.timeout(settings.httpTimeoutMs) }).catch((error) => ({ ok: false, status: error.message }));
|
|
882
|
+
if (!response.ok) settings.warn(`report not delivered: ${response.status}`);
|
|
883
|
+
return response.ok;
|
|
884
|
+
}
|
|
885
|
+
async function installChecked(settings, npm, gitSetting = npmGitSetting) {
|
|
886
|
+
const report = await checkLockfile({ ...settings, lockfile: directoryOf(settings.lockfile) });
|
|
887
|
+
if (report.outcome !== "allowed") return { report, exitCode: exitCodes[report.outcome] };
|
|
888
|
+
const supported = compareCores(await npm.version(), gitSetting.minimumVersion) >= 0;
|
|
889
|
+
const gitFlags = supported ? [`--${gitSetting.name}=none`] : [];
|
|
890
|
+
return { report, exitCode: await npm.run("npm", ["ci", "--ignore-scripts", ...gitFlags], directoryOf(report.lockfile)) };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// src/audit.ts
|
|
894
|
+
var skippedDirs = /* @__PURE__ */ new Set(["node_modules", ".git", ".pnpm_store"]);
|
|
895
|
+
var tarballPattern = /\/((?:@[^/]+(?:\/|%2[fF]))?([^/]+))\/-\/([^/]+)\.tgz$/;
|
|
896
|
+
var claudeSettingsPattern = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
|
|
897
|
+
var vscodeTasksPattern = /(?:^|\/)\.vscode\/tasks\.json$/;
|
|
898
|
+
var folderOpenPattern = /"runOn"\s*:\s*"folderOpen"/;
|
|
899
|
+
var hookAdvice = "Inspect and remove the editor and coding agent hook files listed under hookFiles before revoking any token: they can run again when the project opens and some payloads destroy files when a token is revoked.";
|
|
900
|
+
var installedManifestPattern = /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/@.][^/]*\/package\.json$/;
|
|
901
|
+
function filesUnder(dir) {
|
|
902
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
903
|
+
const path = join4(dir, entry.name);
|
|
904
|
+
if (!entry.isDirectory()) return [path];
|
|
905
|
+
if (entry.name === "node_modules") return existsSync3(join4(path, hiddenLockfileName)) ? [join4(path, hiddenLockfileName)] : [];
|
|
906
|
+
return skippedDirs.has(entry.name) ? [] : filesUnder(path);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
function referencedFiles(root, words) {
|
|
910
|
+
return words.flatMap((word) => {
|
|
911
|
+
const token = word.replace(/["']/g, "").replace(/^\$\{?(CLAUDE_PROJECT_DIR|workspaceFolder)\}?/, root);
|
|
912
|
+
if (!token || token.startsWith("-")) return [];
|
|
913
|
+
const path = isAbsolute(token) ? token : join4(root, token);
|
|
914
|
+
return existsSync3(path) && statSync2(path).isFile() ? [path] : [];
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
function claudeHookFiles(file) {
|
|
918
|
+
const settings = readJson(file);
|
|
919
|
+
const groups = Object.values(settings?.hooks ?? {}).flat();
|
|
920
|
+
if (!groups.length) return [];
|
|
921
|
+
const commands = groups.flatMap((group) => group.hooks ?? []).flatMap((hook) => typeof hook.command === "string" ? [hook.command] : []);
|
|
922
|
+
const root = dirname3(dirname3(file));
|
|
923
|
+
return [file, ...referencedFiles(root, commands.flatMap((command) => command.split(/\s+/)))].map((path) => ({ path, tool: "claude" }));
|
|
924
|
+
}
|
|
925
|
+
function tasksOf(text) {
|
|
926
|
+
try {
|
|
927
|
+
return JSON.parse(text).tasks ?? [];
|
|
928
|
+
} catch (error) {
|
|
929
|
+
if (error instanceof SyntaxError) return [];
|
|
930
|
+
throw error;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function vscodeHookFiles(file) {
|
|
934
|
+
const text = readFileSync2(file, "utf8");
|
|
935
|
+
if (!folderOpenPattern.test(text)) return [];
|
|
936
|
+
const tasks = tasksOf(text.replace(/^\s*\/\/.*$/gm, ""));
|
|
937
|
+
const words = tasks.filter((task) => task.runOptions?.runOn === "folderOpen").flatMap((task) => [task.command, ...Array.isArray(task.args) ? task.args : []]).flatMap((word) => typeof word === "string" ? word.split(/\s+/) : []);
|
|
938
|
+
return [file, ...referencedFiles(dirname3(dirname3(file)), words)].map((path) => ({ path, tool: "vscode" }));
|
|
939
|
+
}
|
|
940
|
+
function scanPath(dir, registryUrl) {
|
|
941
|
+
if (!existsSync3(dir)) return { found: [], problems: [`no directory at ${dir}`], hookFiles: [] };
|
|
942
|
+
const files = filesUnder(dir);
|
|
943
|
+
const auditedNames = [...lockfileNames, hiddenLockfileName];
|
|
944
|
+
const reads = files.filter((file) => auditedNames.includes(basename2(file))).map((file) => readLockfile(file, auditedNames));
|
|
945
|
+
const hookFiles = files.flatMap((file) => {
|
|
946
|
+
if (claudeSettingsPattern.test(file)) return claudeHookFiles(file);
|
|
947
|
+
return vscodeTasksPattern.test(file) ? vscodeHookFiles(file) : [];
|
|
948
|
+
});
|
|
949
|
+
return {
|
|
950
|
+
found: reads.flatMap((read) => read.ok ? read.entries.map(({ name, version }) => ({ name, version, source: read.path })) : []),
|
|
951
|
+
sources: reads.flatMap((read) => read.ok ? nonRegistrySourcesOf(read.entries, read.path, registryUrl) : []),
|
|
952
|
+
problems: reads.flatMap((read) => read.ok ? [] : [read.reason]),
|
|
953
|
+
hookFiles
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function packageFromTarballUrl(url) {
|
|
957
|
+
const match = tarballPattern.exec(url);
|
|
958
|
+
if (!match?.[1] || !match[2] || !match[3]?.startsWith(`${match[2]}-`)) return null;
|
|
959
|
+
return { name: decodeURIComponent(match[1]), version: match[3].slice(match[2].length + 1) };
|
|
960
|
+
}
|
|
961
|
+
function cacheKeysOf(file) {
|
|
962
|
+
return readFileSync2(file, "utf8").split("\n").flatMap((line) => {
|
|
963
|
+
const tab = line.indexOf(" ");
|
|
964
|
+
if (tab === -1) return [];
|
|
965
|
+
try {
|
|
966
|
+
const key = JSON.parse(line.slice(tab + 1)).key;
|
|
967
|
+
return typeof key === "string" ? [key] : [];
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (error instanceof SyntaxError) return [];
|
|
970
|
+
throw error;
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
function scanCache(dir) {
|
|
975
|
+
const index = [join4(dir, "_cacache", "index-v5"), join4(dir, "index-v5")].find((path) => existsSync3(path));
|
|
976
|
+
if (!index) return { found: [], problems: [`no npm cache index under ${dir}`], hookFiles: [] };
|
|
977
|
+
const found = filesUnder(index).flatMap(
|
|
978
|
+
(file) => cacheKeysOf(file).flatMap((key) => {
|
|
979
|
+
const pkg = packageFromTarballUrl(key);
|
|
980
|
+
return pkg ? [{ ...pkg, source: file }] : [];
|
|
981
|
+
})
|
|
982
|
+
);
|
|
983
|
+
return { found, problems: [], hookFiles: [] };
|
|
984
|
+
}
|
|
985
|
+
function readBody(entry) {
|
|
986
|
+
const chunks = [];
|
|
987
|
+
entry.on("data", (chunk) => chunks.push(chunk));
|
|
988
|
+
return new Promise((resolve) => entry.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))));
|
|
989
|
+
}
|
|
990
|
+
function scanLayer(layer, source, scan) {
|
|
991
|
+
const reads = [];
|
|
992
|
+
const parser = new Parser({
|
|
993
|
+
strict: true,
|
|
994
|
+
onReadEntry: (entry) => {
|
|
995
|
+
if (!installedManifestPattern.test(entry.path)) return entry.resume();
|
|
996
|
+
const where = `${source}:${layer.path}:${entry.path}`;
|
|
997
|
+
reads.push(
|
|
998
|
+
readBody(entry).then((text) => {
|
|
999
|
+
try {
|
|
1000
|
+
const manifest = JSON.parse(text);
|
|
1001
|
+
if (typeof manifest.name === "string" && typeof manifest.version === "string") {
|
|
1002
|
+
scan.found.push({ name: manifest.name, version: manifest.version, source: where });
|
|
1003
|
+
}
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
1006
|
+
scan.problems.push(`unreadable package.json at ${where}`);
|
|
1007
|
+
}
|
|
1008
|
+
})
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
const done = new Promise((resolve) => {
|
|
1013
|
+
parser.on("end", resolve);
|
|
1014
|
+
parser.on("error", (error) => {
|
|
1015
|
+
scan.problems.push(`unreadable layer ${source}:${layer.path}: ${error.message}`);
|
|
1016
|
+
resolve();
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
layer.pipe(parser);
|
|
1020
|
+
return done.then(() => Promise.all(reads)).then(() => void 0);
|
|
1021
|
+
}
|
|
1022
|
+
async function layersOf(file) {
|
|
1023
|
+
let manifestText = null;
|
|
1024
|
+
await listTar({
|
|
1025
|
+
file,
|
|
1026
|
+
filter: (path) => path === "manifest.json",
|
|
1027
|
+
onReadEntry: (entry) => {
|
|
1028
|
+
manifestText = readBody(entry);
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
if (!manifestText) return null;
|
|
1032
|
+
try {
|
|
1033
|
+
const manifest = JSON.parse(await manifestText);
|
|
1034
|
+
return new Set(manifest.flatMap((image) => Array.isArray(image.Layers) ? image.Layers.map(String) : []));
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
if (error instanceof SyntaxError || error instanceof TypeError) return null;
|
|
1037
|
+
throw error;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
async function scanImage(file) {
|
|
1041
|
+
if (!existsSync3(file)) return { found: [], problems: [`no saved image at ${file}`], hookFiles: [] };
|
|
1042
|
+
const layers = await layersOf(file);
|
|
1043
|
+
if (!layers) return { found: [], problems: [`no readable manifest.json in ${file}`], hookFiles: [] };
|
|
1044
|
+
const scan = { found: [], problems: [], hookFiles: [] };
|
|
1045
|
+
const scans = [];
|
|
1046
|
+
await listTar({
|
|
1047
|
+
file,
|
|
1048
|
+
onReadEntry: (entry) => {
|
|
1049
|
+
if (layers.has(entry.path)) scans.push(scanLayer(entry, file, scan));
|
|
1050
|
+
else entry.resume();
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
await Promise.all(scans);
|
|
1054
|
+
return scan;
|
|
1055
|
+
}
|
|
1056
|
+
async function audit(settings) {
|
|
1057
|
+
const scans = [
|
|
1058
|
+
...settings.paths.map((path) => scanPath(path, settings.registryUrl ?? defaultRegistryUrl)),
|
|
1059
|
+
...settings.cacheDirs.map(scanCache),
|
|
1060
|
+
...await Promise.all(settings.dockerImages.map(scanImage))
|
|
1061
|
+
];
|
|
1062
|
+
const found = scans.flatMap((scan) => scan.found);
|
|
1063
|
+
const sources = scans.flatMap((scan) => scan.sources ?? []);
|
|
1064
|
+
const git = nonRegistryVerdict(sources, settings.allowNonRegistrySources);
|
|
1065
|
+
const hookFiles = [...new Map(scans.flatMap((scan) => scan.hookFiles).map((hook) => [hook.path, hook])).values()];
|
|
1066
|
+
const list = await loadSealedList(settings);
|
|
1067
|
+
const problems = [...scans.flatMap((scan) => scan.problems), ...list.source === "none" ? ["The Sealed list was unavailable, so installed versions could not be checked."] : []];
|
|
1068
|
+
const hits = found.flatMap(({ name, version, source }) => {
|
|
1069
|
+
const key = sealKeyOf(name, version);
|
|
1070
|
+
return list.keys.has(key) ? [{ name, version, key, lockfilePath: source, alreadyInstalled: true }] : [];
|
|
1071
|
+
});
|
|
1072
|
+
const blocked = [...new Map(hits.map((block) => [`${block.key}${block.lockfilePath}`, block])).values()];
|
|
1073
|
+
const suspect = found.flatMap(({ name, version, source }) => {
|
|
1074
|
+
const key = sealKeyOf(name, version);
|
|
1075
|
+
const info = list.suspects.get(key);
|
|
1076
|
+
return info && !list.keys.has(key) ? [{ name, version, key, lockfilePath: source, ...info, allowed: true }] : [];
|
|
1077
|
+
});
|
|
1078
|
+
const limits = { sealedKeys: list.keys, minAgeDays: settings.minAgeDays, now: settings.now() };
|
|
1079
|
+
const rollback = await proposeRollbacks(blocked, limits, packumentReader(settings.registryUrl, settings.fetchFn, settings.warn, settings.httpTimeoutMs));
|
|
1080
|
+
return {
|
|
1081
|
+
product: productName,
|
|
1082
|
+
service: settings.service,
|
|
1083
|
+
lockfile: [...settings.paths, ...settings.cacheDirs, ...settings.dockerImages].join(", "),
|
|
1084
|
+
checkedAt: settings.now().toISOString(),
|
|
1085
|
+
listSource: list.source,
|
|
1086
|
+
snapshotAgeSeconds: list.snapshotAgeSeconds,
|
|
1087
|
+
fallback: null,
|
|
1088
|
+
packagesChecked: found.length,
|
|
1089
|
+
blocked,
|
|
1090
|
+
suspect,
|
|
1091
|
+
nonRegistrySources: sources,
|
|
1092
|
+
hookFiles,
|
|
1093
|
+
tooYoung: [],
|
|
1094
|
+
rollback,
|
|
1095
|
+
advice: [...problems, ...git.advice, ...hookFiles.length ? [hookAdvice] : [], ...suspectAdvice(suspect), ...adviceFor(blocked)],
|
|
1096
|
+
outcome: blocked.length || git.blocks ? "blocked" : problems.length ? "unavailable" : "allowed"
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/sidecar.ts
|
|
1101
|
+
import { createServer } from "node:http";
|
|
1102
|
+
function listen(server, port) {
|
|
1103
|
+
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server.address().port)));
|
|
1104
|
+
}
|
|
1105
|
+
async function startSidecar(settings) {
|
|
1106
|
+
let status = null;
|
|
1107
|
+
const refresh = async () => {
|
|
1108
|
+
const report = await checkLockfile({ ...settings, assumeInstalled: true, suspectBlocks: false });
|
|
1109
|
+
const changed = status?.report.outcome !== report.outcome;
|
|
1110
|
+
status = { service: settings.service, healthy: report.outcome === "allowed", report, updatedAt: report.checkedAt };
|
|
1111
|
+
if (changed) await sendReport(report, settings);
|
|
1112
|
+
return status;
|
|
1113
|
+
};
|
|
1114
|
+
const server = createServer((request, response) => {
|
|
1115
|
+
const healthy = status?.healthy === true;
|
|
1116
|
+
if (request.url === "/health") return response.writeHead(healthy ? 200 : 503).end(healthy ? "ok" : "blocked or unavailable");
|
|
1117
|
+
if (request.url === "/status" && status) return response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(status));
|
|
1118
|
+
return response.writeHead(request.url === "/status" ? 503 : 404).end();
|
|
1119
|
+
});
|
|
1120
|
+
const timer = setInterval(() => refresh().catch((error) => settings.warn(`sidecar check failed: ${error.message}`)), settings.intervalSeconds * 1e3);
|
|
1121
|
+
const port = await listen(server, settings.port);
|
|
1122
|
+
await refresh();
|
|
1123
|
+
return {
|
|
1124
|
+
url: `http://127.0.0.1:${port}`,
|
|
1125
|
+
refresh,
|
|
1126
|
+
close: () => {
|
|
1127
|
+
clearInterval(timer);
|
|
1128
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// src/cli.ts
|
|
1134
|
+
var usage = "usage: cordon <check|install|audit|rollback|sidecar> [--lockfile path] [--service name] [--path dir]... [--cache dir]... [--docker image.tar]... [--port n] [--allow-suspect name@version]...";
|
|
1135
|
+
var defaultHttpTimeoutMs = 5e3;
|
|
1136
|
+
function numberFrom(env, name, minimum) {
|
|
1137
|
+
const raw = env[name];
|
|
1138
|
+
if (raw === void 0 || raw === "") return null;
|
|
1139
|
+
const value = Number(raw);
|
|
1140
|
+
if (!Number.isInteger(value) || value < minimum) throw new Error(`${name} must be an integer of at least ${minimum}, got ${raw}`);
|
|
1141
|
+
return value;
|
|
1142
|
+
}
|
|
1143
|
+
function textFrom(env, name) {
|
|
1144
|
+
return env[name] ? env[name] : null;
|
|
1145
|
+
}
|
|
1146
|
+
function flagFrom(env, name) {
|
|
1147
|
+
const raw = env[name] ?? "";
|
|
1148
|
+
if (!["", "true", "false"].includes(raw)) throw new Error(`${name} must be true or false, got ${raw}`);
|
|
1149
|
+
return raw === "true";
|
|
1150
|
+
}
|
|
1151
|
+
function settingsFromEnv(env, flags) {
|
|
1152
|
+
return {
|
|
1153
|
+
lockfile: flags.lockfile ?? process.cwd(),
|
|
1154
|
+
service: flags.service ?? null,
|
|
1155
|
+
fallbackMinAgeDays: numberFrom(env, "GUARD_FALLBACK_MIN_AGE_DAYS", 1),
|
|
1156
|
+
minAgeDays: numberFrom(env, "GUARD_MIN_AGE_DAYS", 0) ?? 0,
|
|
1157
|
+
registryUrl: textFrom(env, "NPM_REGISTRY_URL") ?? defaultRegistryUrl,
|
|
1158
|
+
allowNonRegistrySources: flagFrom(env, "GUARD_ALLOW_NON_REGISTRY_SOURCES"),
|
|
1159
|
+
allowSuspect: flags["allow-suspect"] ?? [],
|
|
1160
|
+
suspectBlocks: true,
|
|
1161
|
+
chainId: numberFrom(env, "CHAIN_ID", 1),
|
|
1162
|
+
rpcUrl: textFrom(env, "RPC_URL"),
|
|
1163
|
+
registryAddress: textFrom(env, "REGISTRY_ADDRESS"),
|
|
1164
|
+
registryStartBlock: numberFrom(env, "REGISTRY_START_BLOCK", 0),
|
|
1165
|
+
snapshotUrl: textFrom(env, "GUARD_SNAPSHOT_URL"),
|
|
1166
|
+
snapshotSigner: textFrom(env, "GUARD_SNAPSHOT_SIGNER"),
|
|
1167
|
+
snapshotMaxAgeSeconds: numberFrom(env, "GUARD_SNAPSHOT_MAX_AGE_SECONDS", 1),
|
|
1168
|
+
cacheDir: textFrom(env, "GUARD_CACHE_DIR"),
|
|
1169
|
+
reportUrl: textFrom(env, "GUARD_REPORT_URL"),
|
|
1170
|
+
reporterPrivateKey: textFrom(env, "GUARD_REPORTER_PRIVATE_KEY"),
|
|
1171
|
+
assumeInstalled: false,
|
|
1172
|
+
fetchFn: fetch,
|
|
1173
|
+
httpTimeoutMs: numberFrom(env, "GUARD_HTTP_TIMEOUT_MS", 1) ?? defaultHttpTimeoutMs,
|
|
1174
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
1175
|
+
warn: (message) => process.stderr.write(`${message}
|
|
1176
|
+
`)
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
var systemNpm = {
|
|
1180
|
+
run: (command, args, cwd) => new Promise((resolve, reject) => {
|
|
1181
|
+
const child = spawn(command, args, { cwd, stdio: "inherit" });
|
|
1182
|
+
child.on("error", reject);
|
|
1183
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
1184
|
+
}),
|
|
1185
|
+
version: async () => (await promisify(execFile)("npm", ["--version"])).stdout.trim()
|
|
1186
|
+
};
|
|
1187
|
+
function printJson(value) {
|
|
1188
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
1189
|
+
`);
|
|
1190
|
+
}
|
|
1191
|
+
async function reported(report, settings) {
|
|
1192
|
+
printJson(report);
|
|
1193
|
+
await sendReport(report, settings);
|
|
1194
|
+
return exitCodes[report.outcome];
|
|
1195
|
+
}
|
|
1196
|
+
async function run(argv, env, npm = systemNpm) {
|
|
1197
|
+
const { positionals, values } = parseArgs({
|
|
1198
|
+
args: argv,
|
|
1199
|
+
allowPositionals: true,
|
|
1200
|
+
options: {
|
|
1201
|
+
lockfile: { type: "string" },
|
|
1202
|
+
service: { type: "string" },
|
|
1203
|
+
path: { type: "string", multiple: true },
|
|
1204
|
+
cache: { type: "string", multiple: true },
|
|
1205
|
+
docker: { type: "string", multiple: true },
|
|
1206
|
+
port: { type: "string" },
|
|
1207
|
+
"allow-suspect": { type: "string", multiple: true }
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
const settings = settingsFromEnv(env, values);
|
|
1211
|
+
const command = positionals[0];
|
|
1212
|
+
if (command === "check") return reported(await checkLockfile(settings), settings);
|
|
1213
|
+
if (command === "install") {
|
|
1214
|
+
const { report, exitCode } = await installChecked(settings, npm);
|
|
1215
|
+
await reported(report, settings);
|
|
1216
|
+
return exitCode;
|
|
1217
|
+
}
|
|
1218
|
+
if (command === "audit") {
|
|
1219
|
+
const paths = values.path ?? [];
|
|
1220
|
+
const scope = { paths: paths.length || values.cache || values.docker ? paths : [process.cwd()], cacheDirs: values.cache ?? [], dockerImages: values.docker ?? [] };
|
|
1221
|
+
return reported(await audit({ ...settings, ...scope }), settings);
|
|
1222
|
+
}
|
|
1223
|
+
if (command === "rollback") {
|
|
1224
|
+
const report = await checkLockfile(settings);
|
|
1225
|
+
printJson({ report, ...overridesOf(report.rollback) });
|
|
1226
|
+
return exitCodes[report.outcome];
|
|
1227
|
+
}
|
|
1228
|
+
if (command === "sidecar") {
|
|
1229
|
+
const port = numberFrom({ "--port": values.port }, "--port", 0);
|
|
1230
|
+
if (!values.service || !values.lockfile || port === null) throw new Error("sidecar needs --lockfile, --service and --port");
|
|
1231
|
+
const intervalSeconds = numberFrom(env, "POLL_INTERVAL_SECONDS", 1);
|
|
1232
|
+
if (intervalSeconds === null) throw new Error("sidecar needs POLL_INTERVAL_SECONDS");
|
|
1233
|
+
const sidecar = await startSidecar({ ...settings, service: values.service, port, intervalSeconds });
|
|
1234
|
+
process.stderr.write(`sidecar for ${values.service} listening on ${sidecar.url}
|
|
1235
|
+
`);
|
|
1236
|
+
return new Promise(() => void 0);
|
|
1237
|
+
}
|
|
1238
|
+
throw new Error(usage);
|
|
1239
|
+
}
|
|
1240
|
+
function main() {
|
|
1241
|
+
run(process.argv.slice(2), process.env).then((code) => process.exit(code)).catch((error) => {
|
|
1242
|
+
process.stderr.write(`${error.message}
|
|
1243
|
+
`);
|
|
1244
|
+
process.exit(exitCodes.unavailable);
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
if ("" === process.argv[1]) main();
|
|
1248
|
+
|
|
1249
|
+
// entry.ts
|
|
1250
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "npmcordon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Guard for Cordon: blocks npm package versions sealed in the SealRegistry contract before they install or run. Testnet prototype.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cordon": "dist/cordon.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/SahilKumar75/cordon.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/SahilKumar75/cordon#readme",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"npm",
|
|
25
|
+
"supply chain",
|
|
26
|
+
"security",
|
|
27
|
+
"lockfile",
|
|
28
|
+
"ethereum"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"tar": "^7.4.0",
|
|
32
|
+
"viem": "^2.21.0",
|
|
33
|
+
"zod": "^3.25.0"
|
|
34
|
+
}
|
|
35
|
+
}
|