@danypops/pi-packed 0.19.9 → 0.19.11
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/dist/client.d.ts +109 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1 -0
- package/dist/protocol.d.ts +221 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/extension/src/approval/permission.ts +1 -1
- package/extension/src/packed.ts +2 -2
- package/extension/src/tabs/security-tui.ts +1 -1
- package/extension/src/tool-output.ts +1 -1
- package/package.json +31 -8
- package/service/schema/pi-setup-v1.schema.json +70 -0
- package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
- package/service/src/adoption/advisories.ts +268 -0
- package/service/src/adoption/check.ts +872 -0
- package/service/src/adoption/commit-freshness.ts +167 -0
- package/service/src/adoption/doctor.ts +135 -0
- package/service/src/adoption/install-validation.ts +187 -0
- package/service/src/adoption/pack.ts +291 -0
- package/service/src/adoption/score.ts +466 -0
- package/service/src/adoption/smoke-child.ts +113 -0
- package/service/src/adoption/smoke.ts +282 -0
- package/service/src/cli/cli.ts +926 -0
- package/service/src/daemon/cleanup.ts +76 -0
- package/service/src/daemon/client.ts +412 -0
- package/service/src/daemon/daemon-service.ts +249 -0
- package/service/src/daemon/daemon.ts +110 -0
- package/service/src/daemon/service.ts +664 -0
- package/service/src/daemon/watcher.ts +92 -0
- package/service/src/index/build-index.ts +256 -0
- package/service/src/packages/catalog.ts +61 -0
- package/service/src/packages/db.ts +224 -0
- package/service/src/packages/install.ts +60 -0
- package/service/src/packages/installed.ts +123 -0
- package/service/src/packages/package.ts +141 -0
- package/service/src/packages/resources.ts +203 -0
- package/service/src/pi/pi-version.ts +171 -0
- package/service/src/public/atomic-json.ts +32 -0
- package/service/src/public/client.ts +277 -0
- package/service/src/public/protocol.ts +169 -0
- package/service/src/publish/publish.ts +855 -0
- package/service/src/registry/registry.ts +246 -0
- package/service/src/security/security.ts +128 -0
- package/service/src/self-update/self-update.ts +148 -0
- package/service/src/setup/setup.ts +761 -0
- package/service/src/shared/atomic-json.ts +33 -0
- package/service/src/shared/cache.ts +21 -0
- package/service/src/shared/constants.ts +73 -0
- package/service/src/shared/log.ts +21 -0
- package/service/src/shared/paths.ts +88 -0
- package/service/src/shared/state.ts +15 -0
- package/service/src/shared/version.ts +46 -0
- package/service/test/advisories.test.ts +287 -0
- package/service/test/check.test.ts +368 -0
- package/service/test/cleanup.test.ts +220 -0
- package/service/test/cli.test.ts +1303 -0
- package/service/test/core.test.ts +181 -0
- package/service/test/daemon-kit-migration.test.ts +181 -0
- package/service/test/daemon-service.test.ts +238 -0
- package/service/test/db.test.ts +178 -0
- package/service/test/doctor.test.ts +234 -0
- package/service/test/domain.test.ts +291 -0
- package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
- package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
- package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
- package/service/test/index.test.ts +353 -0
- package/service/test/install-validation.test.ts +114 -0
- package/service/test/install.test.ts +113 -0
- package/service/test/log.test.ts +42 -0
- package/service/test/pack-score.test.ts +513 -0
- package/service/test/pi-version.test.ts +318 -0
- package/service/test/public-boundary.test.ts +54 -0
- package/service/test/public-client.test.ts +127 -0
- package/service/test/public-consumer.ts +8 -0
- package/service/test/publish.test.ts +333 -0
- package/service/test/registry-contract.test.ts +148 -0
- package/service/test/resources.test.ts +255 -0
- package/service/test/security.test.ts +89 -0
- package/service/test/self-update.test.ts +257 -0
- package/service/test/service.test.ts +555 -0
- package/service/test/setup.test.ts +375 -0
- package/service/test/smoke.test.ts +118 -0
- package/service/test/version.test.ts +37 -0
- package/service/tsconfig.consumer.json +13 -0
- package/service/tsconfig.public.json +12 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisories.ts — vulnerability advisory scanning for installed Pi
|
|
3
|
+
* packages. Nothing in Pi or Packed today checks installed dependency
|
|
4
|
+
* trees against known advisories; a third party (exitcode0.net's
|
|
5
|
+
* dep-audit) proved the lockfile-free approach works: batch installed
|
|
6
|
+
* versions per package against npm's own bulk advisory endpoint. Zero
|
|
7
|
+
* code execution, one bounded/timed-out network call for the scan itself.
|
|
8
|
+
*/
|
|
9
|
+
import { compare, satisfies, valid } from "semver";
|
|
10
|
+
import { readInstalledPackages, readResolvedVersion } from "../packages/installed.ts";
|
|
11
|
+
import { NPM_REGISTRY_BASE, REGISTRY_FETCH_TIMEOUT_MS } from "../shared/constants.ts";
|
|
12
|
+
import type { Diagnostic } from "./check.ts";
|
|
13
|
+
|
|
14
|
+
const BULK_ADVISORY_PATH = "/-/npm/v1/security/advisories/bulk";
|
|
15
|
+
const MAX_PACKAGES_PER_SCAN = 200;
|
|
16
|
+
const MAX_ADVISORIES_PER_PACKAGE = 25;
|
|
17
|
+
const MAX_FINDINGS = 200;
|
|
18
|
+
const MAX_BULK_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const MAX_PACKUMENT_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
const SEMVER_OPTS = { loose: true, includePrerelease: true } as const;
|
|
21
|
+
/** A patch published within this window is weaker assurance than one that
|
|
22
|
+
* has aged without a new report -- not yet enough time for the community
|
|
23
|
+
* to catch a bad or incomplete fix (the "poisoned patch" pattern). This is
|
|
24
|
+
* Packed's own threshold, not copied from a source verified at write time;
|
|
25
|
+
* documented explicitly so it can be revisited. */
|
|
26
|
+
export const PATCHED_VERSION_FRESH_DAYS = 7;
|
|
27
|
+
|
|
28
|
+
export type AdvisorySeverity = "critical" | "high" | "moderate" | "low";
|
|
29
|
+
|
|
30
|
+
export interface RawAdvisory {
|
|
31
|
+
id: number | string;
|
|
32
|
+
url: string;
|
|
33
|
+
title: string;
|
|
34
|
+
severity: AdvisorySeverity;
|
|
35
|
+
vulnerableVersions: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PatchedVersionAge {
|
|
39
|
+
version: string;
|
|
40
|
+
publishedAt: string;
|
|
41
|
+
ageDays: number;
|
|
42
|
+
/** true when ageDays < PATCHED_VERSION_FRESH_DAYS -- an explicit, separate
|
|
43
|
+
* signal, never folded into severity. */
|
|
44
|
+
fresh: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AdvisoryFinding {
|
|
48
|
+
packageName: string;
|
|
49
|
+
installedVersion: string;
|
|
50
|
+
id: number | string;
|
|
51
|
+
url: string;
|
|
52
|
+
title: string;
|
|
53
|
+
severity: AdvisorySeverity;
|
|
54
|
+
vulnerableVersions: string;
|
|
55
|
+
/** undefined when no published version outside the vulnerable range
|
|
56
|
+
* could be found (registry lookup failed, or truly nothing fixes it
|
|
57
|
+
* yet) -- never guessed. */
|
|
58
|
+
patchedVersionAge?: PatchedVersionAge;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AdvisoryReport {
|
|
62
|
+
scanned: number;
|
|
63
|
+
findings: AdvisoryFinding[];
|
|
64
|
+
diagnostics: Diagnostic[];
|
|
65
|
+
truncated: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolves the real, on-disk installed version for every npm-sourced Pi
|
|
69
|
+
* package (or just one, when named) -- ground truth from node_modules,
|
|
70
|
+
* never a trusted-but-unverified declared pin. Silently omits a package
|
|
71
|
+
* whose version can't be resolved rather than guessing. git:/local
|
|
72
|
+
* sources are omitted (no meaningful npm advisory lookup applies). */
|
|
73
|
+
export function resolveInstalledVersions(piHome: string, only?: string): Record<string, string> {
|
|
74
|
+
const names = readInstalledPackages(piHome)
|
|
75
|
+
.map((pkg) => pkg.name)
|
|
76
|
+
.filter((name) => !only || name === only);
|
|
77
|
+
const out: Record<string, string> = {};
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const version = readResolvedVersion(piHome, `npm:${name}`);
|
|
80
|
+
if (version) out[name] = version;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function boundedBodyJson(text: string, maxBytes: number): unknown {
|
|
86
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) throw new Error("response exceeded bound");
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One bounded, timed-out POST against npm's real bulk advisory endpoint --
|
|
91
|
+
* lockfile-free by design, matching how Pi itself installs packages (no
|
|
92
|
+
* package-lock.json to audit against). Never throws: any failure (network,
|
|
93
|
+
* timeout, malformed response) resolves to {}, so a scan degrades to "no
|
|
94
|
+
* known advisories" rather than crashing or reporting false negatives as
|
|
95
|
+
* if they were verified-clean. */
|
|
96
|
+
export async function fetchBulkAdvisories(
|
|
97
|
+
packages: Record<string, string[]>,
|
|
98
|
+
options: { registryBase?: string; timeoutMs?: number } = {},
|
|
99
|
+
): Promise<Record<string, RawAdvisory[]>> {
|
|
100
|
+
if (Object.keys(packages).length === 0) return {};
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch(`${options.registryBase ?? NPM_REGISTRY_BASE}${BULK_ADVISORY_PATH}`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
105
|
+
body: JSON.stringify(packages),
|
|
106
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? REGISTRY_FETCH_TIMEOUT_MS),
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok) return {};
|
|
109
|
+
const text = await response.text();
|
|
110
|
+
const data = boundedBodyJson(text, MAX_BULK_RESPONSE_BYTES) as Record<string, unknown>;
|
|
111
|
+
const out: Record<string, RawAdvisory[]> = {};
|
|
112
|
+
for (const [name, entries] of Object.entries(data)) {
|
|
113
|
+
if (!Array.isArray(entries)) continue;
|
|
114
|
+
out[name] = entries
|
|
115
|
+
.slice(0, MAX_ADVISORIES_PER_PACKAGE)
|
|
116
|
+
.map((raw): RawAdvisory | undefined => {
|
|
117
|
+
const entry = raw as Record<string, unknown>;
|
|
118
|
+
if (typeof entry.id !== "number" && typeof entry.id !== "string") return undefined;
|
|
119
|
+
if (typeof entry.vulnerable_versions !== "string") return undefined;
|
|
120
|
+
const severity: AdvisorySeverity =
|
|
121
|
+
entry.severity === "critical" || entry.severity === "high" || entry.severity === "moderate" || entry.severity === "low"
|
|
122
|
+
? entry.severity
|
|
123
|
+
: "high";
|
|
124
|
+
return {
|
|
125
|
+
id: entry.id,
|
|
126
|
+
url: typeof entry.url === "string" ? entry.url.slice(0, 2_048) : "",
|
|
127
|
+
title: typeof entry.title === "string" ? entry.title.slice(0, 512) : "unknown advisory",
|
|
128
|
+
severity,
|
|
129
|
+
vulnerableVersions: entry.vulnerable_versions.slice(0, 512),
|
|
130
|
+
};
|
|
131
|
+
})
|
|
132
|
+
.filter((entry): entry is RawAdvisory => entry !== undefined);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
} catch {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fetches only what's needed from the full packument (version list + publish
|
|
141
|
+
* times) to compute a patched-version-age heuristic -- one bounded,
|
|
142
|
+
* best-effort GET per package that actually has an advisory, never per
|
|
143
|
+
* package scanned. Never throws. */
|
|
144
|
+
async function fetchPackageTimes(
|
|
145
|
+
name: string,
|
|
146
|
+
registryBase: string,
|
|
147
|
+
timeoutMs: number,
|
|
148
|
+
): Promise<{ versions: string[]; time: Record<string, string> } | undefined> {
|
|
149
|
+
try {
|
|
150
|
+
const encoded = encodeURIComponent(name).replace("%2F", "/");
|
|
151
|
+
const response = await fetch(`${registryBase}/${encoded}`, {
|
|
152
|
+
headers: { accept: "application/json" },
|
|
153
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
154
|
+
});
|
|
155
|
+
if (!response.ok) return undefined;
|
|
156
|
+
const text = await response.text();
|
|
157
|
+
const data = boundedBodyJson(text, MAX_PACKUMENT_BYTES) as { versions?: Record<string, unknown>; time?: Record<string, unknown> };
|
|
158
|
+
if (!data.versions || !data.time) return undefined;
|
|
159
|
+
const time: Record<string, string> = {};
|
|
160
|
+
for (const [version, value] of Object.entries(data.time)) if (typeof value === "string") time[version] = value;
|
|
161
|
+
return { versions: Object.keys(data.versions).filter((v) => valid(v)), time };
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The smallest published version that is both newer than the installed one
|
|
168
|
+
* and outside the advisory's vulnerable range -- "the fix", derived the same
|
|
169
|
+
* way npm's own arborist re-resolves an avoided range, not from a
|
|
170
|
+
* patched_versions field (the bulk endpoint doesn't return one; only the
|
|
171
|
+
* full legacy audit report does). */
|
|
172
|
+
function findPatchedVersion(installedVersion: string, vulnerableVersions: string, versions: string[]): string | undefined {
|
|
173
|
+
const candidates = versions
|
|
174
|
+
.filter((v) => valid(v) && compare(v, installedVersion) > 0 && !satisfies(v, vulnerableVersions, SEMVER_OPTS))
|
|
175
|
+
.sort(compare);
|
|
176
|
+
return candidates[0];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function diagnosticFor(finding: AdvisoryFinding): Diagnostic {
|
|
180
|
+
const severityMap: Record<AdvisorySeverity, Diagnostic["severity"]> = {
|
|
181
|
+
critical: "error",
|
|
182
|
+
high: "error",
|
|
183
|
+
moderate: "warning",
|
|
184
|
+
low: "info",
|
|
185
|
+
};
|
|
186
|
+
const freshNote = finding.patchedVersionAge
|
|
187
|
+
? finding.patchedVersionAge.fresh
|
|
188
|
+
? ` (patched in ${finding.patchedVersionAge.version}, published ${finding.patchedVersionAge.ageDays}d ago -- recent enough that the fix itself is a weaker signal)`
|
|
189
|
+
: ` (patched in ${finding.patchedVersionAge.version}, published ${finding.patchedVersionAge.ageDays}d ago)`
|
|
190
|
+
: "";
|
|
191
|
+
return {
|
|
192
|
+
code: "PI_PACKAGE_ADVISORY",
|
|
193
|
+
severity: severityMap[finding.severity],
|
|
194
|
+
path: finding.packageName,
|
|
195
|
+
message: `${finding.title} (${finding.severity}, ${finding.id}) affects installed ${finding.installedVersion}; vulnerable range: ${finding.vulnerableVersions}${freshNote}`,
|
|
196
|
+
...(finding.patchedVersionAge ? { fix: `upgrade to ${finding.patchedVersionAge.version}` } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Scans a bounded set of installed packages (name -> resolved version)
|
|
202
|
+
* against npm's bulk advisory endpoint. No lockfile required, matching how
|
|
203
|
+
* Pi itself installs packages. The patched-version-age heuristic is always
|
|
204
|
+
* a separate, explicit field on each finding -- never silently folded into
|
|
205
|
+
* severity, since a fresh patch and a mature one carry genuinely different
|
|
206
|
+
* trust even at the same advisory severity.
|
|
207
|
+
*/
|
|
208
|
+
export async function scanInstalledPackages(
|
|
209
|
+
installed: Record<string, string>,
|
|
210
|
+
options: { registryBase?: string; timeoutMs?: number } = {},
|
|
211
|
+
): Promise<AdvisoryReport> {
|
|
212
|
+
const registryBase = options.registryBase ?? NPM_REGISTRY_BASE;
|
|
213
|
+
const timeoutMs = options.timeoutMs ?? REGISTRY_FETCH_TIMEOUT_MS;
|
|
214
|
+
const entries = Object.entries(installed).slice(0, MAX_PACKAGES_PER_SCAN);
|
|
215
|
+
const truncatedByCount = Object.keys(installed).length > entries.length;
|
|
216
|
+
const bulkInput: Record<string, string[]> = {};
|
|
217
|
+
for (const [name, version] of entries) bulkInput[name] = [version];
|
|
218
|
+
const advisoriesByPackage = await fetchBulkAdvisories(bulkInput, { registryBase, timeoutMs });
|
|
219
|
+
|
|
220
|
+
const findings: AdvisoryFinding[] = [];
|
|
221
|
+
let truncated = truncatedByCount;
|
|
222
|
+
for (const [name, version] of entries) {
|
|
223
|
+
const advisories = advisoriesByPackage[name];
|
|
224
|
+
if (!advisories || advisories.length === 0) continue;
|
|
225
|
+
let times: { versions: string[]; time: Record<string, string> } | undefined;
|
|
226
|
+
for (const advisory of advisories) {
|
|
227
|
+
if (findings.length >= MAX_FINDINGS) {
|
|
228
|
+
truncated = true;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
let patchedVersionAge: PatchedVersionAge | undefined;
|
|
232
|
+
if (times === undefined) times = (await fetchPackageTimes(name, registryBase, timeoutMs)) ?? { versions: [], time: {} };
|
|
233
|
+
const patched = findPatchedVersion(version, advisory.vulnerableVersions, times.versions);
|
|
234
|
+
if (patched && times.time[patched]) {
|
|
235
|
+
const publishedAt = times.time[patched]!;
|
|
236
|
+
const ageDays = Math.max(0, Math.floor((Date.now() - Date.parse(publishedAt)) / (24 * 60 * 60 * 1000)));
|
|
237
|
+
patchedVersionAge = { version: patched, publishedAt, ageDays, fresh: ageDays < PATCHED_VERSION_FRESH_DAYS };
|
|
238
|
+
}
|
|
239
|
+
findings.push({
|
|
240
|
+
packageName: name,
|
|
241
|
+
installedVersion: version,
|
|
242
|
+
id: advisory.id,
|
|
243
|
+
url: advisory.url,
|
|
244
|
+
title: advisory.title,
|
|
245
|
+
severity: advisory.severity,
|
|
246
|
+
vulnerableVersions: advisory.vulnerableVersions,
|
|
247
|
+
patchedVersionAge,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (findings.length >= MAX_FINDINGS) break;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { scanned: entries.length, findings, diagnostics: findings.map(diagnosticFor), truncated };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const MAX_ADVISORY_OUTPUT = 16 * 1024;
|
|
257
|
+
|
|
258
|
+
/** Same rendering shape as check.ts's formatCheckReport -- one bounded,
|
|
259
|
+
* consistent diagnostic envelope across Packed, not a new ad hoc format. */
|
|
260
|
+
export function formatAdvisoryReport(report: AdvisoryReport, json: boolean): string {
|
|
261
|
+
if (json) return `${JSON.stringify(report)}\n`;
|
|
262
|
+
if (report.diagnostics.length === 0) return `no known advisories for ${report.scanned} scanned package(s)\n`;
|
|
263
|
+
let output = `${report.diagnostics.length} advisory finding(s) across ${report.scanned} scanned package(s)\n`;
|
|
264
|
+
for (const diagnostic of report.diagnostics)
|
|
265
|
+
output += `${diagnostic.severity.toUpperCase()} ${diagnostic.path}: ${diagnostic.message}${diagnostic.fix ? ` Fix: ${diagnostic.fix}` : ""}\n`;
|
|
266
|
+
if (report.truncated) output += "Output truncated by configured bounds.\n";
|
|
267
|
+
return output.slice(0, MAX_ADVISORY_OUTPUT);
|
|
268
|
+
}
|