selfguard 0.0.1 → 0.2.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 +133 -3
- package/dist/activity.js +12 -0
- package/dist/alerts.js +189 -0
- package/dist/announce.js +136 -0
- package/dist/api.js +494 -0
- package/dist/cli.js +591 -0
- package/dist/config.js +76 -0
- package/dist/discover.js +73 -0
- package/dist/events.js +61 -0
- package/dist/fsutil.js +66 -0
- package/dist/guide.js +61 -0
- package/dist/hook.js +114 -0
- package/dist/lockfile/bun.js +113 -0
- package/dist/lockfile/common.js +79 -0
- package/dist/lockfile/detect.js +42 -0
- package/dist/lockfile/index.js +22 -0
- package/dist/lockfile/npm.js +77 -0
- package/dist/lockfile/pnpm.js +121 -0
- package/dist/lockfile/types.js +1 -0
- package/dist/lockfile/yaml.js +140 -0
- package/dist/lockfile/yarn.js +136 -0
- package/dist/match.js +81 -0
- package/dist/mcp.js +248 -0
- package/dist/notify.js +43 -0
- package/dist/osv.js +142 -0
- package/dist/paths.js +16 -0
- package/dist/repoid.js +171 -0
- package/dist/report.js +126 -0
- package/dist/sanitize.js +12 -0
- package/dist/scan.js +197 -0
- package/dist/schedule.js +142 -0
- package/dist/semver.js +65 -0
- package/dist/setup.js +236 -0
- package/dist/state.js +19 -0
- package/dist/store.js +37 -0
- package/dist/tui.js +132 -0
- package/dist/ui/server.js +325 -0
- package/dist/ui/static/app.css +263 -0
- package/dist/ui/static/app.js +482 -0
- package/dist/ui/static/favicon.svg +1 -0
- package/dist/ui/static/index.html +47 -0
- package/dist/version.js +2 -0
- package/package.json +45 -4
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// selfguard dashboard. No frameworks, no third-party code. Every piece of data comes from
|
|
2
|
+
// lockfiles or OSV.dev and is rendered with textContent only (never innerHTML).
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const SEVERITIES = ["malicious", "critical", "high", "moderate", "low"];
|
|
6
|
+
const RANK = { malicious: 0, critical: 1, high: 2, moderate: 3, low: 4, unknown: 5 };
|
|
7
|
+
|
|
8
|
+
const state = {
|
|
9
|
+
token: "",
|
|
10
|
+
overview: null,
|
|
11
|
+
filter: "attention",
|
|
12
|
+
search: "",
|
|
13
|
+
selected: null, // repo key
|
|
14
|
+
detail: null,
|
|
15
|
+
scope: "rep", // packages of the representative checkout, or "all" checkouts
|
|
16
|
+
showAllCheckouts: false,
|
|
17
|
+
busy: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ---------- tiny DOM helpers ----------
|
|
21
|
+
|
|
22
|
+
function h(tag, attrs, ...children) {
|
|
23
|
+
const el = document.createElement(tag);
|
|
24
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
25
|
+
if (v === undefined || v === null || v === false) continue;
|
|
26
|
+
if (k === "class") el.className = v;
|
|
27
|
+
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
|
|
28
|
+
else el.setAttribute(k, v === true ? "" : String(v));
|
|
29
|
+
}
|
|
30
|
+
for (const c of children.flat()) {
|
|
31
|
+
if (c === undefined || c === null || c === false) continue;
|
|
32
|
+
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
33
|
+
}
|
|
34
|
+
return el;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const $ = (id) => document.getElementById(id);
|
|
38
|
+
|
|
39
|
+
function toast(message) {
|
|
40
|
+
const t = $("toast");
|
|
41
|
+
t.textContent = message;
|
|
42
|
+
t.hidden = false;
|
|
43
|
+
clearTimeout(toast.timer);
|
|
44
|
+
toast.timer = setTimeout(() => (t.hidden = true), 3500);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ago(iso) {
|
|
48
|
+
if (!iso) return "never";
|
|
49
|
+
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
|
|
50
|
+
if (s < 60) return "just now";
|
|
51
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
|
52
|
+
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
|
|
53
|
+
const d = Math.floor(s / 86400);
|
|
54
|
+
return d < 60 ? `${d} d ago` : `${Math.floor(d / 30)} mo ago`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tildify(p) {
|
|
58
|
+
return p ? p.replace(/^\/(Users|home)\/[^/]+/, "~") : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const advisoryUrl = (id) => `https://osv.dev/vulnerability/${encodeURIComponent(id)}`;
|
|
62
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
63
|
+
|
|
64
|
+
// ---------- API ----------
|
|
65
|
+
|
|
66
|
+
async function call(method, path, body) {
|
|
67
|
+
const res = await fetch(path, {
|
|
68
|
+
method,
|
|
69
|
+
headers: { "x-selfguard-token": state.token, ...(body ? { "content-type": "application/json" } : {}) },
|
|
70
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
71
|
+
cache: "no-store",
|
|
72
|
+
});
|
|
73
|
+
const data = await res.json().catch(() => ({}));
|
|
74
|
+
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function refresh() {
|
|
79
|
+
try {
|
|
80
|
+
state.overview = await call("GET", "/api/overview");
|
|
81
|
+
render();
|
|
82
|
+
if (state.selected) await loadDetail(state.selected, { quiet: true });
|
|
83
|
+
} catch (e) {
|
|
84
|
+
$("status-line").textContent = `Not connected: ${e.message}. Run "selfguard ui" again.`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function loadDetail(key, opts = {}) {
|
|
89
|
+
try {
|
|
90
|
+
state.detail = await call("GET", `/api/repo?key=${encodeURIComponent(key)}`);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
state.detail = null;
|
|
93
|
+
state.selected = null;
|
|
94
|
+
if (!opts.quiet) toast(e.message);
|
|
95
|
+
}
|
|
96
|
+
renderDetail();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function action(button, label, fn) {
|
|
100
|
+
if (state.busy) return;
|
|
101
|
+
state.busy = true;
|
|
102
|
+
const original = button?.textContent;
|
|
103
|
+
if (button) {
|
|
104
|
+
button.disabled = true;
|
|
105
|
+
button.textContent = label;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await fn();
|
|
109
|
+
} catch (e) {
|
|
110
|
+
toast(e.message);
|
|
111
|
+
} finally {
|
|
112
|
+
state.busy = false;
|
|
113
|
+
if (button) {
|
|
114
|
+
button.disabled = false;
|
|
115
|
+
button.textContent = original;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------- derived data ----------
|
|
121
|
+
|
|
122
|
+
function attentionSet() {
|
|
123
|
+
return new Set(state.overview?.needsAttention || []);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The checkout that speaks for a repo: active main clone, else the most recent active one. */
|
|
127
|
+
function representative(g) {
|
|
128
|
+
const active = g.checkouts.filter((k) => k.active);
|
|
129
|
+
const recent = (a, b) => Date.parse(b.lastActivity || 0) - Date.parse(a.lastActivity || 0);
|
|
130
|
+
return active.find((k) => !k.worktree) || active.sort(recent)[0] || g.checkouts[0];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function groupsFor(filter) {
|
|
134
|
+
const repos = state.overview?.repos || [];
|
|
135
|
+
const att = attentionSet();
|
|
136
|
+
const q = state.search.trim().toLowerCase();
|
|
137
|
+
return repos
|
|
138
|
+
.filter((g) => {
|
|
139
|
+
if (filter === "attention") return att.has(g.key);
|
|
140
|
+
if (filter === "active") return g.active;
|
|
141
|
+
return !g.active;
|
|
142
|
+
})
|
|
143
|
+
.filter((g) => !q || g.label.toLowerCase().includes(q) || g.checkouts.some((k) => k.path.toLowerCase().includes(q)))
|
|
144
|
+
.map((g) => ({ g, rep: representative(g) }))
|
|
145
|
+
.sort((a, b) =>
|
|
146
|
+
(RANK[a.rep.worstSeverity] ?? 9) - (RANK[b.rep.worstSeverity] ?? 9) ||
|
|
147
|
+
b.rep.vulnerablePackages - a.rep.vulnerablePackages ||
|
|
148
|
+
a.g.label.localeCompare(b.g.label))
|
|
149
|
+
.map(({ g }) => g);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- rendering ----------
|
|
153
|
+
|
|
154
|
+
function render() {
|
|
155
|
+
const o = state.overview;
|
|
156
|
+
if (!o) return;
|
|
157
|
+
|
|
158
|
+
// status line
|
|
159
|
+
const parts = [`${o.activeCheckouts} active checkouts (${o.trackedProjects} tracked)`];
|
|
160
|
+
if (o.schedule) {
|
|
161
|
+
parts.push(o.schedule.enabled ? `background sync on${o.schedule.watcher ? ", lockfile watcher on" : ""}` : "background sync OFF");
|
|
162
|
+
}
|
|
163
|
+
parts.push(`last sync ${ago(o.settings.lastSync)}`);
|
|
164
|
+
$("status-line").textContent = parts.join(" · ");
|
|
165
|
+
|
|
166
|
+
// hero
|
|
167
|
+
const att = o.needsAttention.length;
|
|
168
|
+
const hero = $("hero");
|
|
169
|
+
hero.className = `hero ${att ? "bad" : "ok"}`;
|
|
170
|
+
$("hero-title").textContent = att ? `${plural(att, "repo")} need${att === 1 ? "s" : ""} attention` : "All clear";
|
|
171
|
+
$("hero-sub").textContent = att
|
|
172
|
+
? "Malicious packages, critical vulnerabilities, or high ones in direct dependencies, in repos you are working on (dev-only excluded)."
|
|
173
|
+
: "Nothing serious in the repos you are working on. Lower-severity findings are listed under Active.";
|
|
174
|
+
$("tiles").replaceChildren(
|
|
175
|
+
...SEVERITIES.map((s) =>
|
|
176
|
+
h("div", { class: `tile${o.totals[s] ? "" : " zero"}`, "data-sev": s, title: `${s} packages in active checkouts` },
|
|
177
|
+
h("div", { class: "n" }, o.totals[s]),
|
|
178
|
+
h("div", { class: "l" }, s)),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// tabs
|
|
183
|
+
for (const b of $("tabs").querySelectorAll("button")) {
|
|
184
|
+
const f = b.dataset.filter;
|
|
185
|
+
b.setAttribute("aria-selected", String(f === state.filter));
|
|
186
|
+
const label = { attention: "Attention", active: "Active", inactive: "Inactive" }[f];
|
|
187
|
+
b.replaceChildren(label, h("span", { class: "count" }, groupsFor(f).length));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// list
|
|
191
|
+
const list = groupsFor(state.filter);
|
|
192
|
+
const ul = $("repo-list");
|
|
193
|
+
if (list.length === 0) {
|
|
194
|
+
const msg = {
|
|
195
|
+
attention: "Nothing needs attention right now.",
|
|
196
|
+
active: "No active repos match.",
|
|
197
|
+
inactive: "No inactive repos.",
|
|
198
|
+
}[state.filter];
|
|
199
|
+
ul.replaceChildren(h("li", { class: "empty" }, msg));
|
|
200
|
+
} else {
|
|
201
|
+
ul.replaceChildren(
|
|
202
|
+
...list.map((g) => {
|
|
203
|
+
const rep = representative(g);
|
|
204
|
+
const sev = rep.worstSeverity || (rep.error ? "error" : "ok");
|
|
205
|
+
const shown = g.active ? g.checkouts.filter((k) => k.active) : g.checkouts;
|
|
206
|
+
const worse = shown.filter((k) => k !== rep && k.vulnerablePackages > rep.vulnerablePackages).length;
|
|
207
|
+
const meta = [
|
|
208
|
+
rep.vulnerablePackages ? `${plural(rep.vulnerablePackages, "vulnerable package")}` : "clean",
|
|
209
|
+
rep.branch ? `on ${rep.branch}` : "",
|
|
210
|
+
shown.length > 1 ? `${shown.length} checkouts${worse ? ` (${worse} worse)` : ""}` : "",
|
|
211
|
+
].filter(Boolean).join(" · ");
|
|
212
|
+
return h("li", {},
|
|
213
|
+
h("button", {
|
|
214
|
+
type: "button",
|
|
215
|
+
"aria-current": String(g.key === state.selected),
|
|
216
|
+
onclick: () => select(g.key),
|
|
217
|
+
},
|
|
218
|
+
h("span", { class: "dot", "data-sev": sev }),
|
|
219
|
+
h("span", {}, h("div", { class: "name" }, g.label), h("div", { class: "meta" }, meta)),
|
|
220
|
+
rep.worstSeverity ? h("span", { class: "badge", "data-sev": rep.worstSeverity }, rep.worstSeverity) : h("span", { class: "chip ok" }, "ok")));
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!state.selected) renderHome();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function select(key) {
|
|
229
|
+
state.selected = key;
|
|
230
|
+
state.detail = null;
|
|
231
|
+
state.scope = "rep";
|
|
232
|
+
state.showAllCheckouts = false;
|
|
233
|
+
render();
|
|
234
|
+
renderDetail();
|
|
235
|
+
loadDetail(key);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderHome() {
|
|
239
|
+
const o = state.overview;
|
|
240
|
+
const panel = $("detail");
|
|
241
|
+
const reminder = h("select", { id: "remind" },
|
|
242
|
+
...["daily", "weekly", "off"].map((v) => h("option", { value: v, selected: o.settings.remind === v }, v)));
|
|
243
|
+
reminder.addEventListener("change", () =>
|
|
244
|
+
action(null, "", async () => {
|
|
245
|
+
await call("POST", "/api/settings", { remind: reminder.value });
|
|
246
|
+
toast(`Reminder: ${reminder.value}`);
|
|
247
|
+
await refresh();
|
|
248
|
+
}));
|
|
249
|
+
const days = h("input", { type: "number", min: "1", max: "365", value: o.settings.activeDays });
|
|
250
|
+
days.addEventListener("change", () =>
|
|
251
|
+
action(null, "", async () => {
|
|
252
|
+
await call("POST", "/api/settings", { activeDays: Number(days.value) });
|
|
253
|
+
toast(`Active window: ${days.value} days`);
|
|
254
|
+
await refresh();
|
|
255
|
+
}));
|
|
256
|
+
const testBtn = h("button", { class: "btn small", type: "button" }, "Send reminder now");
|
|
257
|
+
testBtn.addEventListener("click", () =>
|
|
258
|
+
action(testBtn, "Sending…", async () => {
|
|
259
|
+
const r = await call("POST", "/api/remind", {});
|
|
260
|
+
toast(r.sent ? "Reminder sent (check your notifications)" : "Nothing serious to remind about");
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
const schedule = o.schedule;
|
|
264
|
+
const scheduleNotice =
|
|
265
|
+
schedule && !schedule.enabled
|
|
266
|
+
? h("div", { class: "notice" },
|
|
267
|
+
"Background watching is off, so selfguard can't alert you on its own. Turn it on with ",
|
|
268
|
+
h("code", {}, "selfguard schedule on"), ".")
|
|
269
|
+
: null;
|
|
270
|
+
|
|
271
|
+
panel.replaceChildren(
|
|
272
|
+
...[
|
|
273
|
+
h("div", { class: "detail-head" }, h("h2", {}, "Activity")),
|
|
274
|
+
scheduleNotice,
|
|
275
|
+
renderFeed(o.events),
|
|
276
|
+
h("h3", {}, "Alerts"),
|
|
277
|
+
h("div", { class: "settings" },
|
|
278
|
+
h("div", { class: "setting" },
|
|
279
|
+
h("label", { for: "remind" }, "Reminder"),
|
|
280
|
+
reminder,
|
|
281
|
+
h("p", {}, "A notification about open malicious, critical or high issues in active repos. Sent between 9:00 and 21:00."),
|
|
282
|
+
h("div", { class: "gap-top" }, testBtn),
|
|
283
|
+
o.settings.lastReminder ? h("p", {}, `Last sent ${ago(o.settings.lastReminder)}.`) : null),
|
|
284
|
+
h("div", { class: "setting" },
|
|
285
|
+
h("label", {}, "Active window (days)"),
|
|
286
|
+
days,
|
|
287
|
+
h("p", {}, "Checkouts without git activity for longer are inactive: listed, but they only alert for malicious packages.")),
|
|
288
|
+
h("div", { class: "setting" },
|
|
289
|
+
h("label", {}, "Instant checks"),
|
|
290
|
+
h("p", {}, schedule?.watcher
|
|
291
|
+
? `Watching ${schedule.watchedPaths} lockfiles and folders. A git pull, install or merge is checked within seconds.`
|
|
292
|
+
: "Lockfile watcher is not running. It starts with background watching (selfguard schedule on).")),
|
|
293
|
+
),
|
|
294
|
+
].filter(Boolean),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderFeed(events) {
|
|
299
|
+
const visible = (events || []).filter((e) => !(e.kind === "new" && e.firstScan)).slice(0, 40);
|
|
300
|
+
if (visible.length === 0) {
|
|
301
|
+
return h("p", { class: "muted" }, "No activity yet. New advisories, fixes and reminders will show up here.");
|
|
302
|
+
}
|
|
303
|
+
return h("ul", { class: "feed" },
|
|
304
|
+
...visible.map((e) => {
|
|
305
|
+
const icon = { new: "⚠️", resolved: "✅", reminder: "🔔" }[e.kind] || "•";
|
|
306
|
+
const what =
|
|
307
|
+
e.kind === "new" ? `${plural(e.total, "new vulnerable package")} in ${e.repo}`
|
|
308
|
+
: e.kind === "resolved" ? `${plural(e.total, "vulnerable package")} fixed in ${e.repo}`
|
|
309
|
+
: `Reminder sent: ${e.repo}`;
|
|
310
|
+
const pkgs = (e.packages || []).slice(0, 4).map((p) => `${p.name}@${p.version}`).join(", ");
|
|
311
|
+
const more = e.total > 4 && e.packages?.length ? ` +${e.total - 4} more` : "";
|
|
312
|
+
return h("li", {},
|
|
313
|
+
h("span", { class: "icon", "aria-hidden": "true" }, icon),
|
|
314
|
+
h("div", {},
|
|
315
|
+
h("div", {}, what, e.branch ? h("span", { class: "muted" }, ` · ${e.branch}`) : null, e.active === false ? h("span", { class: "chip gap-left" }, "inactive") : null),
|
|
316
|
+
pkgs ? h("div", { class: "pkgs mono" }, pkgs + more) : null),
|
|
317
|
+
h("span", { class: "when", title: e.at }, ago(e.at)));
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function renderDetail() {
|
|
322
|
+
const panel = $("detail");
|
|
323
|
+
if (!state.selected) {
|
|
324
|
+
renderHome();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const back = h("button", { class: "btn small", type: "button", onclick: () => { state.selected = null; state.detail = null; render(); } }, "← Activity");
|
|
328
|
+
const d = state.detail;
|
|
329
|
+
if (!d) {
|
|
330
|
+
panel.replaceChildren(h("div", { class: "detail-head" }, back), h("p", { class: "muted" }, "Loading…"));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const rep = representative(d);
|
|
335
|
+
const listed = state.showAllCheckouts ? d.checkouts : d.checkouts.slice(0, 4);
|
|
336
|
+
const checkouts = h("div", { class: "checkouts" },
|
|
337
|
+
...listed.map((k) => {
|
|
338
|
+
const rescan = h("button", { class: "btn small", type: "button" }, "Re-scan");
|
|
339
|
+
rescan.addEventListener("click", () =>
|
|
340
|
+
action(rescan, "Scanning…", async () => {
|
|
341
|
+
const r = await call("POST", "/api/scan", { path: k.path });
|
|
342
|
+
toast(r.project.vulnerablePackages ? `${plural(r.project.vulnerablePackages, "vulnerable package")}` : "No known vulnerabilities");
|
|
343
|
+
await refresh();
|
|
344
|
+
}));
|
|
345
|
+
const ignore = h("button", { class: "btn small danger", type: "button", title: "Stop tracking this folder and exclude it from discovery" }, "Ignore");
|
|
346
|
+
ignore.addEventListener("click", () => {
|
|
347
|
+
if (!confirm(`Stop tracking ${k.path}?\n\nIt will be excluded from discovery. Undo with: selfguard unexclude <path>`)) return;
|
|
348
|
+
action(ignore, "…", async () => {
|
|
349
|
+
await call("POST", "/api/forget", { path: k.path });
|
|
350
|
+
toast("Ignored");
|
|
351
|
+
await refresh();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
const status = k.error && !k.vulnerablePackages
|
|
355
|
+
? h("span", { class: "chip", title: k.error }, "error")
|
|
356
|
+
: k.vulnerablePackages
|
|
357
|
+
? h("span", { class: "badge", "data-sev": k.worstSeverity }, `${k.vulnerablePackages} vulnerable`)
|
|
358
|
+
: h("span", { class: "chip ok" }, "clean");
|
|
359
|
+
return h("div", { class: `checkout${k.active ? "" : " inactive"}` },
|
|
360
|
+
h("div", {},
|
|
361
|
+
h("div", {},
|
|
362
|
+
h("strong", {}, k.branch || "(no branch)"), " ",
|
|
363
|
+
k.worktree ? h("span", { class: "chip" }, "worktree") : null, " ",
|
|
364
|
+
k.active ? null : h("span", { class: "chip" }, `inactive${k.lastActivity ? ` · ${ago(k.lastActivity)}` : ""}`), " ",
|
|
365
|
+
status),
|
|
366
|
+
h("div", { class: "path mono", title: k.path }, tildify(k.path)),
|
|
367
|
+
k.error ? h("div", { class: "path" }, k.error) : null),
|
|
368
|
+
h("div", { class: "actions" }, rescan, ignore));
|
|
369
|
+
}));
|
|
370
|
+
|
|
371
|
+
const moreCheckouts = d.checkouts.length > listed.length
|
|
372
|
+
? h("button", { class: "btn small gap-top", type: "button", onclick: () => { state.showAllCheckouts = true; renderDetail(); } },
|
|
373
|
+
`Show all ${d.checkouts.length} checkouts`)
|
|
374
|
+
: null;
|
|
375
|
+
|
|
376
|
+
const many = d.checkouts.length > 1;
|
|
377
|
+
const pkgs = state.scope === "rep" && many ? d.packages.filter((p) => p.checkouts.some((c) => c.path === rep.path)) : d.packages;
|
|
378
|
+
const scopeToggle = many
|
|
379
|
+
? h("div", { class: "tabs scope" },
|
|
380
|
+
...[["rep", `On ${rep.branch || "main checkout"}`], ["all", "All checkouts"]].map(([v, label]) =>
|
|
381
|
+
h("button", { type: "button", "aria-selected": String(state.scope === v), onclick: () => { state.scope = v; renderDetail(); } }, label)))
|
|
382
|
+
: null;
|
|
383
|
+
|
|
384
|
+
let table;
|
|
385
|
+
if (pkgs.length === 0) {
|
|
386
|
+
table = h("p", { class: "muted" }, state.scope === "rep" && many ? `No known vulnerabilities on ${rep.branch || "this checkout"}. 🎉` : "No known vulnerabilities in any checkout. 🎉");
|
|
387
|
+
} else {
|
|
388
|
+
table = h("table", {},
|
|
389
|
+
h("thead", {}, h("tr", {}, h("th", {}, "Severity"), h("th", {}, "Package"), h("th", {}, "Fix"), h("th", {}, "Where"))),
|
|
390
|
+
h("tbody", {},
|
|
391
|
+
...pkgs.map((p) => {
|
|
392
|
+
const activeHere = p.checkouts.some((c) => c.active);
|
|
393
|
+
const fix = p.worstSeverity === "malicious"
|
|
394
|
+
? h("span", { class: "fix-remove" }, "Remove it, rotate credentials")
|
|
395
|
+
: p.upgradeTo
|
|
396
|
+
? h("span", { class: "fix-ok" }, `→ ${p.upgradeTo}`)
|
|
397
|
+
: h("span", { class: "fix-none" }, "No fix yet");
|
|
398
|
+
const kind = [p.direct ? "direct" : "transitive", p.dev ? "dev" : null].filter(Boolean).join(" · ");
|
|
399
|
+
const branches = [...new Set(p.checkouts.map((c) => c.branch || "?"))];
|
|
400
|
+
return h("tr", { class: activeHere ? "" : "dim" },
|
|
401
|
+
h("td", {}, h("span", { class: "badge", "data-sev": p.worstSeverity }, p.worstSeverity)),
|
|
402
|
+
h("td", {},
|
|
403
|
+
h("div", { class: "mono" }, `${p.name}@${p.version}`),
|
|
404
|
+
h("div", { class: "muted small" }, kind),
|
|
405
|
+
h("details", { class: "adv" },
|
|
406
|
+
h("summary", {}, plural(p.advisories.length, "advisory")),
|
|
407
|
+
h("ul", {},
|
|
408
|
+
...p.advisories.map((a) =>
|
|
409
|
+
h("li", {},
|
|
410
|
+
h("a", { href: advisoryUrl(a.id), target: "_blank", rel: "noopener noreferrer" }, a.id),
|
|
411
|
+
" ", h("span", { class: "muted" }, a.severity),
|
|
412
|
+
a.summary ? h("div", {}, a.summary) : null))))),
|
|
413
|
+
h("td", { class: "fix" }, fix, p.advisoriesWithoutFix && p.upgradeTo ? h("div", { class: "muted small" }, `${p.advisoriesWithoutFix} without fix`) : null),
|
|
414
|
+
h("td", {}, h("div", { class: "small" }, branches.slice(0, 3).join(", ") + (branches.length > 3 ? ` +${branches.length - 3}` : ""))));
|
|
415
|
+
})));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const serious = pkgs.filter((p) => p.worstSeverity === "malicious" || (!p.dev && (p.worstSeverity === "critical" || (p.worstSeverity === "high" && p.direct)))).length;
|
|
419
|
+
const direct = pkgs.filter((p) => p.direct && p.upgradeTo).length;
|
|
420
|
+
const summary = pkgs.length
|
|
421
|
+
? h("p", { class: "muted" },
|
|
422
|
+
`${plural(pkgs.length, "vulnerable package")}, ${serious} serious. `,
|
|
423
|
+
direct ? `${direct === 1 ? "1 direct dependency" : `${direct} direct dependencies`} can be bumped directly; the rest are transitive (upgrade the package that pulls them in, or refresh the lockfile).` : "All are transitive: upgrade the packages that pull them in, or refresh the lockfile.")
|
|
424
|
+
: null;
|
|
425
|
+
|
|
426
|
+
panel.replaceChildren(
|
|
427
|
+
...[
|
|
428
|
+
h("div", { class: "detail-head" }, h("h2", {}, d.label), back),
|
|
429
|
+
h("div", { class: "detail-head gap-top" }, h("h3", { class: "inline" }, "Vulnerable packages"), scopeToggle),
|
|
430
|
+
summary,
|
|
431
|
+
table,
|
|
432
|
+
h("h3", {}, `Checkouts (${d.checkouts.length})`),
|
|
433
|
+
checkouts,
|
|
434
|
+
moreCheckouts,
|
|
435
|
+
].filter(Boolean),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------- boot ----------
|
|
440
|
+
|
|
441
|
+
function boot() {
|
|
442
|
+
const params = new URLSearchParams(location.hash.slice(1));
|
|
443
|
+
const t = params.get("t");
|
|
444
|
+
if (t && /^[A-Za-z0-9_-]+$/.test(t)) sessionStorage.setItem("selfguard-token", t);
|
|
445
|
+
const deepLink = params.get("repo");
|
|
446
|
+
if (location.hash) history.replaceState(null, "", location.pathname);
|
|
447
|
+
state.token = sessionStorage.getItem("selfguard-token") || "";
|
|
448
|
+
|
|
449
|
+
for (const b of $("tabs").querySelectorAll("button")) {
|
|
450
|
+
b.addEventListener("click", () => {
|
|
451
|
+
state.filter = b.dataset.filter;
|
|
452
|
+
render();
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
$("search").addEventListener("input", (e) => {
|
|
456
|
+
state.search = e.target.value;
|
|
457
|
+
render();
|
|
458
|
+
});
|
|
459
|
+
const syncBtn = $("sync-btn");
|
|
460
|
+
syncBtn.addEventListener("click", () =>
|
|
461
|
+
action(syncBtn, "Syncing…", async () => {
|
|
462
|
+
const r = await call("POST", "/api/sync", {});
|
|
463
|
+
const fresh = r.news.reduce((n, p) => n + p.packages.length, 0);
|
|
464
|
+
toast(`Synced ${r.projects} projects${fresh ? `, ${fresh} newly vulnerable packages` : ", nothing new"}`);
|
|
465
|
+
await refresh();
|
|
466
|
+
}));
|
|
467
|
+
|
|
468
|
+
refresh().then(() => {
|
|
469
|
+
// Open on the most useful tab.
|
|
470
|
+
if (state.overview && state.overview.needsAttention.length === 0) {
|
|
471
|
+
state.filter = "active";
|
|
472
|
+
render();
|
|
473
|
+
}
|
|
474
|
+
if (deepLink) {
|
|
475
|
+
const g = state.overview?.repos.find((r) => r.key === deepLink || r.label === deepLink);
|
|
476
|
+
if (g) select(g.key);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
setInterval(refresh, 30_000);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
boot();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16 2 4 6.5v8.7c0 7.4 5.1 13.3 12 14.8 6.9-1.5 12-7.4 12-14.8V6.5L16 2Z" fill="#1f6feb"/><path d="m10.5 16.2 3.8 3.8 7.4-7.6" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<title>selfguard</title>
|
|
8
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
9
|
+
<link rel="stylesheet" href="/app.css">
|
|
10
|
+
<script src="/app.js" defer></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<header class="topbar">
|
|
14
|
+
<div class="brand"><img src="/favicon.svg" alt="" width="22" height="22"><span>selfguard</span></div>
|
|
15
|
+
<div class="status-line" id="status-line">Loading…</div>
|
|
16
|
+
<button class="btn" id="sync-btn" type="button">Sync now</button>
|
|
17
|
+
</header>
|
|
18
|
+
|
|
19
|
+
<main>
|
|
20
|
+
<section class="hero" id="hero" aria-live="polite">
|
|
21
|
+
<div class="hero-text">
|
|
22
|
+
<h1 id="hero-title">…</h1>
|
|
23
|
+
<p id="hero-sub" class="muted"></p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="tiles" id="tiles"></div>
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
<div class="layout">
|
|
29
|
+
<section class="panel repos-panel" aria-label="Repositories">
|
|
30
|
+
<div class="panel-head">
|
|
31
|
+
<div class="tabs" role="tablist" id="tabs">
|
|
32
|
+
<button role="tab" data-filter="attention" type="button">Attention</button>
|
|
33
|
+
<button role="tab" data-filter="active" type="button">Active</button>
|
|
34
|
+
<button role="tab" data-filter="inactive" type="button">Inactive</button>
|
|
35
|
+
</div>
|
|
36
|
+
<input type="search" id="search" placeholder="Filter repos…" autocomplete="off" spellcheck="false">
|
|
37
|
+
</div>
|
|
38
|
+
<ul class="repo-list" id="repo-list"></ul>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<section class="panel detail-panel" id="detail" aria-live="polite"></section>
|
|
42
|
+
</div>
|
|
43
|
+
</main>
|
|
44
|
+
|
|
45
|
+
<div class="toast" id="toast" role="status" hidden></div>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,8 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "selfguard",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local-first guard that watches every JS repo you work on and alerts you about vulnerable or malicious dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/josseed/selfguard.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/josseed/selfguard#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/josseed/selfguard/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"selfguard": "dist/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.10"
|
|
25
|
+
},
|
|
26
|
+
"os": [
|
|
27
|
+
"darwin",
|
|
28
|
+
"linux"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "node scripts/clean.mjs && tsc -p tsconfig.build.json && node scripts/postbuild.mjs",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"test": "node --test \"test/**/*.test.ts\"",
|
|
34
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"security",
|
|
38
|
+
"vulnerabilities",
|
|
39
|
+
"osv",
|
|
40
|
+
"npm",
|
|
41
|
+
"supply-chain",
|
|
42
|
+
"audit",
|
|
43
|
+
"cli"
|
|
44
|
+
],
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "24.19.2",
|
|
47
|
+
"typescript": "7.0.2"
|
|
48
|
+
}
|
|
8
49
|
}
|