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,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local dashboard: a tiny HTTP server on 127.0.0.1 serving a static page and a JSON API.
|
|
3
|
+
*
|
|
4
|
+
* Security:
|
|
5
|
+
* - binds to 127.0.0.1 only; the Host header must be 127.0.0.1:<port> or localhost:<port>
|
|
6
|
+
* (blocks DNS rebinding);
|
|
7
|
+
* - every API call needs a random per-session token in the X-Selfguard-Token header, which
|
|
8
|
+
* other websites can't send without a CORS preflight we never answer;
|
|
9
|
+
* - POSTs also need a JSON content type and a same-origin Origin header;
|
|
10
|
+
* - strict CSP: no inline scripts, no third-party anything. Untrusted text (package names,
|
|
11
|
+
* advisory summaries) is rendered with textContent only;
|
|
12
|
+
* - actions only apply to projects selfguard already tracks.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
16
|
+
import { fchmodSync, openSync, readFileSync, rmSync } from "node:fs";
|
|
17
|
+
import { createServer, request } from "node:http";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { maybeRemind } from "../announce.js";
|
|
20
|
+
import { needsAttention } from "../alerts.js";
|
|
21
|
+
import * as api from "../api.js";
|
|
22
|
+
import { readEvents } from "../events.js";
|
|
23
|
+
import { atomicWrite, ensureDir, isObject, readJsonFile } from "../fsutil.js";
|
|
24
|
+
import { cliEntry } from "../hook.js";
|
|
25
|
+
import { severityRank } from "../match.js";
|
|
26
|
+
import { logFile, stateRoot, uiFile } from "../paths.js";
|
|
27
|
+
import { groupByPackage } from "../report.js";
|
|
28
|
+
import { scheduleStatus, updateWatcher } from "../schedule.js";
|
|
29
|
+
import { loadRepo } from "../store.js";
|
|
30
|
+
import { VERSION } from "../version.js";
|
|
31
|
+
const IDLE_MS = 30 * 60 * 1000;
|
|
32
|
+
const MAX_BODY = 64 * 1024;
|
|
33
|
+
const STATIC = {
|
|
34
|
+
"/": { file: "index.html", type: "text/html; charset=utf-8" },
|
|
35
|
+
"/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
|
|
36
|
+
"/app.css": { file: "app.css", type: "text/css; charset=utf-8" },
|
|
37
|
+
"/favicon.svg": { file: "favicon.svg", type: "image/svg+xml" },
|
|
38
|
+
};
|
|
39
|
+
const CSP = [
|
|
40
|
+
"default-src 'none'",
|
|
41
|
+
"script-src 'self'",
|
|
42
|
+
"style-src 'self'",
|
|
43
|
+
"img-src 'self' data:",
|
|
44
|
+
"connect-src 'self'",
|
|
45
|
+
"base-uri 'none'",
|
|
46
|
+
"form-action 'none'",
|
|
47
|
+
"frame-ancestors 'none'",
|
|
48
|
+
].join("; ");
|
|
49
|
+
const staticDir = () => fileURLToPath(new URL("./static/", import.meta.url));
|
|
50
|
+
export function getOverview() {
|
|
51
|
+
const status = api.getStatus();
|
|
52
|
+
const attention = api.getAttention();
|
|
53
|
+
const totals = { malicious: 0, critical: 0, high: 0, moderate: 0, low: 0, unknown: 0 };
|
|
54
|
+
const worst = new Map();
|
|
55
|
+
for (const r of attention) {
|
|
56
|
+
for (const g of groupByPackage(r.findings)) {
|
|
57
|
+
const k = `${g.name}@${g.version}`;
|
|
58
|
+
const prev = worst.get(k);
|
|
59
|
+
if (!prev || severityRank(g.worst) < severityRank(prev))
|
|
60
|
+
worst.set(k, g.worst);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const s of worst.values())
|
|
64
|
+
totals[s]++;
|
|
65
|
+
let schedule = null;
|
|
66
|
+
if (process.platform === "darwin") {
|
|
67
|
+
try {
|
|
68
|
+
const s = scheduleStatus();
|
|
69
|
+
schedule = { enabled: s.loaded, watcher: s.watcher.loaded, watchedPaths: s.watcher.paths };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
schedule = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
version: VERSION,
|
|
77
|
+
generatedAt: new Date().toISOString(),
|
|
78
|
+
settings: api.getSettings(),
|
|
79
|
+
schedule,
|
|
80
|
+
totals,
|
|
81
|
+
needsAttention: attention.filter((r) => r.findings.some(needsAttention)).map((r) => r.key),
|
|
82
|
+
trackedProjects: status.projects.length,
|
|
83
|
+
activeCheckouts: status.projects.filter((p) => p.active && p.exists).length,
|
|
84
|
+
repos: status.repos,
|
|
85
|
+
events: readEvents(80),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function send(res, status, body, type) {
|
|
89
|
+
res.writeHead(status, { "content-type": type, "content-length": Buffer.byteLength(body) });
|
|
90
|
+
res.end(body);
|
|
91
|
+
}
|
|
92
|
+
function sendJson(res, status, data) {
|
|
93
|
+
send(res, status, JSON.stringify(data), "application/json; charset=utf-8");
|
|
94
|
+
}
|
|
95
|
+
function readBody(req) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let size = 0;
|
|
98
|
+
const chunks = [];
|
|
99
|
+
req.on("data", (c) => {
|
|
100
|
+
size += c.length;
|
|
101
|
+
if (size > MAX_BODY) {
|
|
102
|
+
reject(new api.ApiError("too_large", "request body too large"));
|
|
103
|
+
req.destroy();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
chunks.push(c);
|
|
107
|
+
});
|
|
108
|
+
req.on("end", () => {
|
|
109
|
+
try {
|
|
110
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
111
|
+
const data = text ? JSON.parse(text) : {};
|
|
112
|
+
resolve(isObject(data) ? data : {});
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
reject(new api.ApiError("invalid", "invalid JSON body"));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
req.on("error", reject);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function tokenOk(given, token) {
|
|
122
|
+
if (typeof given !== "string")
|
|
123
|
+
return false;
|
|
124
|
+
const a = Buffer.from(given);
|
|
125
|
+
const b = Buffer.from(token);
|
|
126
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
127
|
+
}
|
|
128
|
+
/** Only act on projects selfguard already tracks (never arbitrary paths). */
|
|
129
|
+
function trackedPath(body) {
|
|
130
|
+
const path = body.path;
|
|
131
|
+
if (typeof path !== "string" || !loadRepo(path))
|
|
132
|
+
throw new api.ApiError("not_tracked", "not a tracked project");
|
|
133
|
+
return path;
|
|
134
|
+
}
|
|
135
|
+
async function route(method, url, req) {
|
|
136
|
+
const p = url.pathname;
|
|
137
|
+
if (method === "GET") {
|
|
138
|
+
if (p === "/api/ping")
|
|
139
|
+
return { ok: true, version: VERSION };
|
|
140
|
+
if (p === "/api/overview")
|
|
141
|
+
return getOverview();
|
|
142
|
+
if (p === "/api/repo")
|
|
143
|
+
return api.getRepoDetail(url.searchParams.get("key") ?? "");
|
|
144
|
+
if (p === "/api/vuln")
|
|
145
|
+
return api.getVulnerability(url.searchParams.get("id") ?? "");
|
|
146
|
+
}
|
|
147
|
+
if (method === "POST") {
|
|
148
|
+
const body = await readBody(req);
|
|
149
|
+
if (p === "/api/scan")
|
|
150
|
+
return api.scanPath(trackedPath(body), { notify: false });
|
|
151
|
+
if (p === "/api/sync") {
|
|
152
|
+
const { outcomes: _o, ...r } = await api.sync({ notify: true });
|
|
153
|
+
try {
|
|
154
|
+
updateWatcher(api.watchTargets());
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* watcher is best effort */
|
|
158
|
+
}
|
|
159
|
+
return r;
|
|
160
|
+
}
|
|
161
|
+
if (p === "/api/forget")
|
|
162
|
+
return api.forget(trackedPath(body), { exclude: true });
|
|
163
|
+
if (p === "/api/settings")
|
|
164
|
+
return api.updateSettings({ remind: body.remind, activeDays: body.activeDays });
|
|
165
|
+
if (p === "/api/remind")
|
|
166
|
+
return { sent: await maybeRemind(api.getAttention(), { force: true }) };
|
|
167
|
+
}
|
|
168
|
+
throw new api.ApiError("not_found", "no such endpoint");
|
|
169
|
+
}
|
|
170
|
+
const STATUS = { not_found: 404, not_tracked: 404, busy: 409, too_large: 413 };
|
|
171
|
+
export function createUiServer(token, onActivity = () => { }) {
|
|
172
|
+
const server = createServer((req, res) => {
|
|
173
|
+
const port = server.address()?.port ?? 0;
|
|
174
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
175
|
+
res.setHeader("referrer-policy", "no-referrer");
|
|
176
|
+
res.setHeader("x-frame-options", "DENY");
|
|
177
|
+
res.setHeader("cache-control", "no-store");
|
|
178
|
+
res.setHeader("cross-origin-opener-policy", "same-origin");
|
|
179
|
+
res.setHeader("cross-origin-resource-policy", "same-origin");
|
|
180
|
+
res.setHeader("content-security-policy", CSP);
|
|
181
|
+
const host = req.headers.host ?? "";
|
|
182
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
183
|
+
send(res, 421, "wrong host", "text/plain; charset=utf-8");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
187
|
+
const method = req.method ?? "GET";
|
|
188
|
+
if (!url.pathname.startsWith("/api/")) {
|
|
189
|
+
const asset = STATIC[url.pathname];
|
|
190
|
+
if (!asset || (method !== "GET" && method !== "HEAD")) {
|
|
191
|
+
send(res, 404, "not found", "text/plain; charset=utf-8");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
send(res, 200, readFileSync(`${staticDir()}${asset.file}`), asset.type);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
send(res, 500, "dashboard files missing", "text/plain; charset=utf-8");
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!tokenOk(req.headers["x-selfguard-token"], token)) {
|
|
203
|
+
sendJson(res, 401, { error: { code: "unauthorized", message: "missing or wrong token; reopen with selfguard ui" } });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (method === "POST") {
|
|
207
|
+
const origin = req.headers.origin;
|
|
208
|
+
const type = req.headers["content-type"] ?? "";
|
|
209
|
+
if ((origin !== undefined && origin !== `http://${host}`) || !type.startsWith("application/json")) {
|
|
210
|
+
sendJson(res, 403, { error: { code: "forbidden", message: "cross-origin request refused" } });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
onActivity();
|
|
215
|
+
route(method, url, req).then((data) => sendJson(res, 200, data), (e) => {
|
|
216
|
+
const code = e instanceof api.ApiError ? e.code : "error";
|
|
217
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
218
|
+
sendJson(res, e instanceof api.ApiError ? (STATUS[code] ?? 400) : 500, { error: { code, message } });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
return server;
|
|
222
|
+
}
|
|
223
|
+
function urlOf(i) {
|
|
224
|
+
return `http://127.0.0.1:${i.port}/#t=${i.token}`;
|
|
225
|
+
}
|
|
226
|
+
function ping(i) {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const req = request({ host: "127.0.0.1", port: i.port, path: "/api/ping", headers: { "x-selfguard-token": i.token }, timeout: 1500 }, (res) => {
|
|
229
|
+
res.resume();
|
|
230
|
+
resolve(res.statusCode === 200);
|
|
231
|
+
});
|
|
232
|
+
req.on("error", () => resolve(false));
|
|
233
|
+
req.on("timeout", () => {
|
|
234
|
+
req.destroy();
|
|
235
|
+
resolve(false);
|
|
236
|
+
});
|
|
237
|
+
req.end();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
async function runningInstance() {
|
|
241
|
+
const i = readJsonFile(uiFile());
|
|
242
|
+
if (!i || !isObject(i) || typeof i.port !== "number" || typeof i.token !== "string" || typeof i.pid !== "number")
|
|
243
|
+
return null;
|
|
244
|
+
return (await ping(i)) ? i : null;
|
|
245
|
+
}
|
|
246
|
+
function openBrowser(url) {
|
|
247
|
+
const cmd = process.platform === "darwin" ? "/usr/bin/open" : "xdg-open";
|
|
248
|
+
const child = spawn(cmd, [url], { stdio: "ignore", detached: true });
|
|
249
|
+
child.on("error", () => { });
|
|
250
|
+
child.unref();
|
|
251
|
+
}
|
|
252
|
+
/** Serve in this process until idle for 30 minutes (or Ctrl+C). */
|
|
253
|
+
export async function serve(port, onReady) {
|
|
254
|
+
const token = randomBytes(24).toString("base64url");
|
|
255
|
+
let timer;
|
|
256
|
+
const server = createUiServer(token, () => bump());
|
|
257
|
+
const stop = () => {
|
|
258
|
+
server.close();
|
|
259
|
+
const current = readJsonFile(uiFile());
|
|
260
|
+
if (current?.pid === process.pid)
|
|
261
|
+
rmSync(uiFile(), { force: true });
|
|
262
|
+
};
|
|
263
|
+
const bump = () => {
|
|
264
|
+
if (timer)
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
timer = setTimeout(stop, IDLE_MS);
|
|
267
|
+
timer.unref();
|
|
268
|
+
};
|
|
269
|
+
await new Promise((resolve, reject) => {
|
|
270
|
+
server.once("error", reject);
|
|
271
|
+
server.listen(port, "127.0.0.1", resolve);
|
|
272
|
+
});
|
|
273
|
+
const instance = { pid: process.pid, port: server.address().port, token };
|
|
274
|
+
atomicWrite(uiFile(), JSON.stringify(instance));
|
|
275
|
+
bump();
|
|
276
|
+
process.once("SIGINT", () => {
|
|
277
|
+
stop();
|
|
278
|
+
process.exit(0);
|
|
279
|
+
});
|
|
280
|
+
process.once("SIGTERM", () => {
|
|
281
|
+
stop();
|
|
282
|
+
process.exit(0);
|
|
283
|
+
});
|
|
284
|
+
onReady(instance);
|
|
285
|
+
await new Promise((resolve) => server.once("close", () => resolve()));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* `selfguard ui`: reuse a running dashboard or start one in the background, then open it.
|
|
289
|
+
* With `foreground`, serve in this process instead (used by `ui --serve`).
|
|
290
|
+
*/
|
|
291
|
+
export async function runUi(opts) {
|
|
292
|
+
if (opts.foreground) {
|
|
293
|
+
await serve(opts.port, (i) => {
|
|
294
|
+
// The URL carries the session token: only show it on a terminal, never in the log file.
|
|
295
|
+
opts.log(process.stdout.isTTY ? `selfguard dashboard: ${urlOf(i)}` : `${new Date().toISOString()} ui: listening on 127.0.0.1:${i.port}`);
|
|
296
|
+
if (opts.open)
|
|
297
|
+
openBrowser(urlOf(i));
|
|
298
|
+
});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
let instance = await runningInstance();
|
|
302
|
+
if (!instance) {
|
|
303
|
+
ensureDir(stateRoot());
|
|
304
|
+
rmSync(uiFile(), { force: true });
|
|
305
|
+
const logFd = openSync(logFile(), "a", 0o600);
|
|
306
|
+
fchmodSync(logFd, 0o600);
|
|
307
|
+
const child = spawn(process.execPath, [cliEntry(), "ui", "--serve", "--no-open", "--port", String(opts.port)], {
|
|
308
|
+
detached: true,
|
|
309
|
+
stdio: ["ignore", logFd, logFd],
|
|
310
|
+
env: process.env,
|
|
311
|
+
});
|
|
312
|
+
child.unref();
|
|
313
|
+
for (let i = 0; i < 50 && !instance; i++) {
|
|
314
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
315
|
+
instance = await runningInstance();
|
|
316
|
+
}
|
|
317
|
+
if (!instance)
|
|
318
|
+
throw new Error("the dashboard did not start; see ~/.selfguard/selfguard.log");
|
|
319
|
+
}
|
|
320
|
+
const url = urlOf(instance);
|
|
321
|
+
opts.log(`Dashboard: ${url}`);
|
|
322
|
+
opts.log("It stops by itself after 30 minutes without use.");
|
|
323
|
+
if (opts.open)
|
|
324
|
+
openBrowser(url);
|
|
325
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f6f7f9;
|
|
3
|
+
--panel: #ffffff;
|
|
4
|
+
--text: #1b1f24;
|
|
5
|
+
--muted: #5f6b7a;
|
|
6
|
+
--border: #e3e7ec;
|
|
7
|
+
--hover: #f1f4f8;
|
|
8
|
+
--accent: #1f6feb;
|
|
9
|
+
--malicious: #8b1a1a;
|
|
10
|
+
--critical: #c62828;
|
|
11
|
+
--high: #e4572e;
|
|
12
|
+
--moderate: #c88a04;
|
|
13
|
+
--low: #6b7785;
|
|
14
|
+
--ok: #1a7f37;
|
|
15
|
+
--radius: 10px;
|
|
16
|
+
color-scheme: light dark;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root {
|
|
21
|
+
--bg: #0f1216;
|
|
22
|
+
--panel: #171b21;
|
|
23
|
+
--text: #e6e9ee;
|
|
24
|
+
--muted: #8f9aa7;
|
|
25
|
+
--border: #262c35;
|
|
26
|
+
--hover: #1e242c;
|
|
27
|
+
--accent: #4c8dff;
|
|
28
|
+
--malicious: #ff6b6b;
|
|
29
|
+
--critical: #ff5c5c;
|
|
30
|
+
--high: #ff8a5c;
|
|
31
|
+
--moderate: #e7b43c;
|
|
32
|
+
--low: #8f9aa7;
|
|
33
|
+
--ok: #3fb950;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
* { box-sizing: border-box; }
|
|
38
|
+
|
|
39
|
+
body {
|
|
40
|
+
margin: 0;
|
|
41
|
+
background: var(--bg);
|
|
42
|
+
color: var(--text);
|
|
43
|
+
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.muted { color: var(--muted); }
|
|
47
|
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
|
|
48
|
+
|
|
49
|
+
.topbar {
|
|
50
|
+
position: sticky;
|
|
51
|
+
top: 0;
|
|
52
|
+
z-index: 5;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 16px;
|
|
56
|
+
padding: 10px 24px;
|
|
57
|
+
background: var(--panel);
|
|
58
|
+
border-bottom: 1px solid var(--border);
|
|
59
|
+
}
|
|
60
|
+
.brand { display: flex; align-items: center; gap: 8px; font-weight: 650; font-size: 15px; }
|
|
61
|
+
.status-line { flex: 1; color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
62
|
+
|
|
63
|
+
.btn {
|
|
64
|
+
border: 1px solid var(--border);
|
|
65
|
+
background: var(--panel);
|
|
66
|
+
color: var(--text);
|
|
67
|
+
border-radius: 7px;
|
|
68
|
+
padding: 6px 12px;
|
|
69
|
+
font: inherit;
|
|
70
|
+
font-size: 13px;
|
|
71
|
+
cursor: pointer;
|
|
72
|
+
}
|
|
73
|
+
.btn:hover { background: var(--hover); }
|
|
74
|
+
.btn:disabled { opacity: .55; cursor: progress; }
|
|
75
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
76
|
+
.btn.danger { color: var(--critical); }
|
|
77
|
+
.btn.small { padding: 3px 9px; font-size: 12px; }
|
|
78
|
+
|
|
79
|
+
main { max-width: 1320px; margin: 0 auto; padding: 20px 24px 40px; }
|
|
80
|
+
|
|
81
|
+
.hero {
|
|
82
|
+
display: flex;
|
|
83
|
+
justify-content: space-between;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: 24px;
|
|
86
|
+
flex-wrap: wrap;
|
|
87
|
+
margin-bottom: 18px;
|
|
88
|
+
}
|
|
89
|
+
.hero h1 { margin: 0 0 2px; font-size: 22px; font-weight: 650; }
|
|
90
|
+
.hero p { margin: 0; }
|
|
91
|
+
.hero.bad h1 { color: var(--critical); }
|
|
92
|
+
.hero.ok h1 { color: var(--ok); }
|
|
93
|
+
|
|
94
|
+
.tiles { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
95
|
+
.tile {
|
|
96
|
+
min-width: 96px;
|
|
97
|
+
padding: 10px 14px;
|
|
98
|
+
background: var(--panel);
|
|
99
|
+
border: 1px solid var(--border);
|
|
100
|
+
border-radius: var(--radius);
|
|
101
|
+
}
|
|
102
|
+
.tile .n { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
103
|
+
.tile .l { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
104
|
+
.tile.zero .n { color: var(--muted); font-weight: 500; }
|
|
105
|
+
.tile[data-sev="malicious"]:not(.zero) .n { color: var(--malicious); }
|
|
106
|
+
.tile[data-sev="critical"]:not(.zero) .n { color: var(--critical); }
|
|
107
|
+
.tile[data-sev="high"]:not(.zero) .n { color: var(--high); }
|
|
108
|
+
.tile[data-sev="moderate"]:not(.zero) .n { color: var(--moderate); }
|
|
109
|
+
|
|
110
|
+
.layout { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 16px; align-items: start; }
|
|
111
|
+
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
|
112
|
+
|
|
113
|
+
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); }
|
|
114
|
+
.repos-panel { position: sticky; top: 64px; max-height: calc(100vh - 84px); display: flex; flex-direction: column; }
|
|
115
|
+
.panel-head { padding: 10px; border-bottom: 1px solid var(--border); display: grid; gap: 8px; }
|
|
116
|
+
.tabs { display: flex; gap: 4px; }
|
|
117
|
+
.tabs button {
|
|
118
|
+
flex: 1;
|
|
119
|
+
border: 0;
|
|
120
|
+
background: transparent;
|
|
121
|
+
color: var(--muted);
|
|
122
|
+
padding: 6px 8px;
|
|
123
|
+
border-radius: 6px;
|
|
124
|
+
font: inherit;
|
|
125
|
+
font-size: 13px;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
}
|
|
128
|
+
.tabs button[aria-selected="true"] { background: var(--hover); color: var(--text); font-weight: 600; }
|
|
129
|
+
.tabs .count { margin-left: 4px; font-variant-numeric: tabular-nums; opacity: .8; }
|
|
130
|
+
input[type="search"], select, input[type="number"] {
|
|
131
|
+
width: 100%;
|
|
132
|
+
border: 1px solid var(--border);
|
|
133
|
+
background: var(--bg);
|
|
134
|
+
color: var(--text);
|
|
135
|
+
border-radius: 7px;
|
|
136
|
+
padding: 6px 10px;
|
|
137
|
+
font: inherit;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.repo-list { list-style: none; margin: 0; padding: 4px; overflow: auto; }
|
|
141
|
+
.repo-list li button {
|
|
142
|
+
width: 100%;
|
|
143
|
+
display: grid;
|
|
144
|
+
grid-template-columns: 10px 1fr auto;
|
|
145
|
+
gap: 10px;
|
|
146
|
+
align-items: center;
|
|
147
|
+
text-align: left;
|
|
148
|
+
border: 0;
|
|
149
|
+
background: transparent;
|
|
150
|
+
color: inherit;
|
|
151
|
+
padding: 9px 10px;
|
|
152
|
+
border-radius: 7px;
|
|
153
|
+
font: inherit;
|
|
154
|
+
cursor: pointer;
|
|
155
|
+
}
|
|
156
|
+
.repo-list li button:hover { background: var(--hover); }
|
|
157
|
+
.repo-list li button[aria-current="true"] { background: var(--hover); box-shadow: inset 3px 0 0 var(--accent); }
|
|
158
|
+
.repo-list .name { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
159
|
+
.repo-list .meta { font-size: 12px; color: var(--muted); }
|
|
160
|
+
.repo-list .empty { padding: 24px 12px; color: var(--muted); text-align: center; }
|
|
161
|
+
|
|
162
|
+
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--ok); }
|
|
163
|
+
.dot[data-sev="malicious"] { background: var(--malicious); box-shadow: 0 0 0 3px color-mix(in srgb, var(--malicious) 25%, transparent); }
|
|
164
|
+
.dot[data-sev="critical"] { background: var(--critical); }
|
|
165
|
+
.dot[data-sev="high"] { background: var(--high); }
|
|
166
|
+
.dot[data-sev="moderate"] { background: var(--moderate); }
|
|
167
|
+
.dot[data-sev="low"], .dot[data-sev="unknown"] { background: var(--low); }
|
|
168
|
+
.dot[data-sev="error"] { background: transparent; border: 2px solid var(--moderate); }
|
|
169
|
+
|
|
170
|
+
.badge {
|
|
171
|
+
display: inline-block;
|
|
172
|
+
padding: 1px 7px;
|
|
173
|
+
border-radius: 999px;
|
|
174
|
+
font-size: 11px;
|
|
175
|
+
font-weight: 650;
|
|
176
|
+
letter-spacing: .03em;
|
|
177
|
+
text-transform: uppercase;
|
|
178
|
+
color: #fff;
|
|
179
|
+
background: var(--low);
|
|
180
|
+
white-space: nowrap;
|
|
181
|
+
}
|
|
182
|
+
.badge[data-sev="malicious"] { background: var(--malicious); }
|
|
183
|
+
.badge[data-sev="critical"] { background: var(--critical); }
|
|
184
|
+
.badge[data-sev="high"] { background: var(--high); }
|
|
185
|
+
.badge[data-sev="moderate"] { background: var(--moderate); }
|
|
186
|
+
.chip {
|
|
187
|
+
display: inline-block;
|
|
188
|
+
padding: 1px 7px;
|
|
189
|
+
border-radius: 999px;
|
|
190
|
+
border: 1px solid var(--border);
|
|
191
|
+
font-size: 11.5px;
|
|
192
|
+
color: var(--muted);
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
}
|
|
195
|
+
.chip.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, var(--border)); }
|
|
196
|
+
|
|
197
|
+
.detail-panel { padding: 18px 20px; min-height: 320px; }
|
|
198
|
+
.detail-panel h2 { margin: 0; font-size: 18px; }
|
|
199
|
+
.detail-panel h3 { margin: 22px 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
|
|
200
|
+
.detail-head { display: flex; align-items: center; gap: 10px; justify-content: space-between; flex-wrap: wrap; }
|
|
201
|
+
|
|
202
|
+
table { width: 100%; border-collapse: collapse; }
|
|
203
|
+
th, td { text-align: left; padding: 8px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
204
|
+
th { font-size: 12px; color: var(--muted); font-weight: 600; }
|
|
205
|
+
tr.dim td { opacity: .6; }
|
|
206
|
+
td.fix { white-space: nowrap; }
|
|
207
|
+
.fix-ok { color: var(--ok); font-weight: 600; }
|
|
208
|
+
.fix-none { color: var(--moderate); }
|
|
209
|
+
.fix-remove { color: var(--malicious); font-weight: 650; }
|
|
210
|
+
|
|
211
|
+
details.adv summary { cursor: pointer; color: var(--muted); font-size: 12.5px; }
|
|
212
|
+
details.adv ul { margin: 6px 0 0; padding-left: 16px; }
|
|
213
|
+
details.adv li { margin: 3px 0; }
|
|
214
|
+
a { color: var(--accent); text-decoration: none; }
|
|
215
|
+
a:hover { text-decoration: underline; }
|
|
216
|
+
|
|
217
|
+
.checkouts { display: grid; gap: 6px; }
|
|
218
|
+
.checkout {
|
|
219
|
+
display: grid;
|
|
220
|
+
grid-template-columns: 1fr auto;
|
|
221
|
+
gap: 8px;
|
|
222
|
+
align-items: center;
|
|
223
|
+
padding: 8px 10px;
|
|
224
|
+
border: 1px solid var(--border);
|
|
225
|
+
border-radius: 8px;
|
|
226
|
+
}
|
|
227
|
+
.checkout .path { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
228
|
+
.checkout .actions { display: flex; gap: 6px; }
|
|
229
|
+
.checkout.inactive { opacity: .65; }
|
|
230
|
+
|
|
231
|
+
.feed { list-style: none; padding: 0; margin: 0; }
|
|
232
|
+
.feed li { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
|
233
|
+
.feed .icon { font-size: 14px; text-align: center; }
|
|
234
|
+
.feed .when { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
235
|
+
.feed .pkgs { color: var(--muted); font-size: 12.5px; }
|
|
236
|
+
|
|
237
|
+
.settings { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
|
238
|
+
.setting { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
|
239
|
+
.setting label { display: block; font-weight: 600; margin-bottom: 4px; }
|
|
240
|
+
.setting p { margin: 6px 0 0; font-size: 12.5px; color: var(--muted); }
|
|
241
|
+
|
|
242
|
+
.notice { padding: 10px 12px; border-radius: 8px; background: var(--hover); margin-top: 10px; font-size: 13px; }
|
|
243
|
+
.notice code { font-size: 12px; }
|
|
244
|
+
|
|
245
|
+
.toast {
|
|
246
|
+
position: fixed;
|
|
247
|
+
bottom: 20px;
|
|
248
|
+
left: 50%;
|
|
249
|
+
transform: translateX(-50%);
|
|
250
|
+
background: var(--text);
|
|
251
|
+
color: var(--bg);
|
|
252
|
+
padding: 8px 14px;
|
|
253
|
+
border-radius: 8px;
|
|
254
|
+
font-size: 13px;
|
|
255
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, .2);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.small { font-size: 12px; }
|
|
259
|
+
.gap-left { margin-left: 6px; }
|
|
260
|
+
.gap-top { margin-top: 8px; }
|
|
261
|
+
.tabs.scope { flex: 0 0 auto; }
|
|
262
|
+
.tabs.scope button { white-space: nowrap; }
|
|
263
|
+
h3.inline { margin: 0 !important; }
|