selfguard 0.1.0 → 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/README.md +100 -119
- package/dist/activity.js +12 -0
- package/dist/alerts.js +94 -1
- package/dist/announce.js +136 -0
- package/dist/api.js +186 -19
- package/dist/cli.js +158 -29
- package/dist/config.js +7 -1
- package/dist/events.js +61 -0
- package/dist/guide.js +10 -2
- package/dist/mcp.js +37 -1
- package/dist/paths.js +3 -0
- package/dist/repoid.js +26 -0
- package/dist/scan.js +32 -3
- package/dist/schedule.js +87 -27
- package/dist/setup.js +7 -4
- package/dist/state.js +19 -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 +1 -1
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
|
-
import {
|
|
8
|
+
import { activityOf } from "./activity.js";
|
|
9
|
+
import { announce } from "./announce.js";
|
|
10
|
+
import { clampDepth, DEFAULT_DEPTH, isExcluded, loadConfig, normalizeDir, REMIND_VALUES, saveConfig, } from "./config.js";
|
|
9
11
|
import { discoverAll } from "./discover.js";
|
|
10
12
|
import { tryLock } from "./fsutil.js";
|
|
11
13
|
import { findProject } from "./lockfile/detect.js";
|
|
@@ -14,7 +16,8 @@ import { getVulns } from "./osv.js";
|
|
|
14
16
|
import { countBySeverity, groupByPackage, worstSeverity } from "./report.js";
|
|
15
17
|
import { repoIdentity } from "./repoid.js";
|
|
16
18
|
import { clean } from "./sanitize.js";
|
|
17
|
-
import { scanProjects } from "./scan.js";
|
|
19
|
+
import { lockChanged, scanProjects } from "./scan.js";
|
|
20
|
+
import { loadState, updateState } from "./state.js";
|
|
18
21
|
import { forgetRepo, listRepos, loadRepo, saveRepo } from "./store.js";
|
|
19
22
|
export class ApiError extends Error {
|
|
20
23
|
code;
|
|
@@ -25,10 +28,13 @@ export class ApiError extends Error {
|
|
|
25
28
|
}
|
|
26
29
|
const advisoryUrl = (id) => `https://osv.dev/vulnerability/${encodeURIComponent(id)}`;
|
|
27
30
|
// ---------- helpers ----------
|
|
28
|
-
function summarize(rec) {
|
|
31
|
+
function summarize(rec, activeDays = loadConfig().activeDays) {
|
|
32
|
+
const exists = existsSync(rec.path);
|
|
33
|
+
const repo = exists ? repoIdentity(rec.path) : null;
|
|
34
|
+
const activity = exists ? activityOf(repo, activeDays) : { active: false, lastActivity: null };
|
|
29
35
|
return {
|
|
30
36
|
path: rec.path,
|
|
31
|
-
exists
|
|
37
|
+
exists,
|
|
32
38
|
lockfile: rec.lockfile,
|
|
33
39
|
lockType: rec.lockType,
|
|
34
40
|
packages: rec.deps.length,
|
|
@@ -39,7 +45,9 @@ function summarize(rec) {
|
|
|
39
45
|
vulnerablePackages: groupByPackage(rec.findings).length,
|
|
40
46
|
advisories: rec.findings.length,
|
|
41
47
|
bySeverity: countBySeverity(rec.findings),
|
|
42
|
-
repo
|
|
48
|
+
repo,
|
|
49
|
+
active: activity.active,
|
|
50
|
+
lastActivity: activity.lastActivity,
|
|
43
51
|
};
|
|
44
52
|
}
|
|
45
53
|
export function toReport(rec) {
|
|
@@ -78,7 +86,8 @@ function resolveProject(path) {
|
|
|
78
86
|
export function getStatus(opts = {}) {
|
|
79
87
|
const rank = (w) => (w ? severityRank(w) : 99);
|
|
80
88
|
const records = listRepos();
|
|
81
|
-
const
|
|
89
|
+
const activeDays = loadConfig().activeDays;
|
|
90
|
+
const summaries = records.map((r) => summarize(r, activeDays));
|
|
82
91
|
const groups = new Map();
|
|
83
92
|
summaries.forEach((p, i) => {
|
|
84
93
|
const rec = records[i];
|
|
@@ -86,38 +95,114 @@ export function getStatus(opts = {}) {
|
|
|
86
95
|
let g = groups.get(key);
|
|
87
96
|
if (!g) {
|
|
88
97
|
const label = p.repo?.label ?? (p.path.split("/").pop() || p.path);
|
|
89
|
-
g = {
|
|
98
|
+
g = {
|
|
99
|
+
group: { key, label, active: false, worstSeverity: null, vulnerablePackages: 0, inactiveCheckouts: 0, checkouts: [] },
|
|
100
|
+
active: [],
|
|
101
|
+
all: [],
|
|
102
|
+
};
|
|
90
103
|
groups.set(key, g);
|
|
91
104
|
}
|
|
92
105
|
g.group.checkouts.push({
|
|
93
106
|
path: p.path,
|
|
94
107
|
branch: p.repo?.branch ?? null,
|
|
95
108
|
worktree: p.repo?.worktree ?? false,
|
|
109
|
+
orphaned: p.repo?.orphaned ?? false,
|
|
96
110
|
exists: p.exists,
|
|
111
|
+
active: p.active,
|
|
112
|
+
lastActivity: p.lastActivity,
|
|
97
113
|
worstSeverity: p.worstSeverity,
|
|
98
114
|
vulnerablePackages: p.vulnerablePackages,
|
|
99
115
|
advisories: p.advisories,
|
|
100
116
|
error: p.error,
|
|
101
117
|
});
|
|
102
|
-
|
|
103
|
-
g.
|
|
104
|
-
|
|
118
|
+
if (p.active)
|
|
119
|
+
g.active.push(...rec.findings);
|
|
120
|
+
else
|
|
121
|
+
g.group.inactiveCheckouts++;
|
|
122
|
+
g.all.push(...rec.findings);
|
|
105
123
|
});
|
|
106
124
|
const repos = [...groups.values()]
|
|
107
|
-
.map(({ group,
|
|
108
|
-
group.
|
|
109
|
-
group.
|
|
110
|
-
|
|
111
|
-
group.
|
|
125
|
+
.map(({ group, active, all }) => {
|
|
126
|
+
group.active = group.checkouts.some((k) => k.active);
|
|
127
|
+
const counted = group.active ? active : all;
|
|
128
|
+
group.worstSeverity = worstSeverity(counted);
|
|
129
|
+
group.vulnerablePackages = new Set(counted.map((f) => `${f.name}@${f.version}`)).size;
|
|
130
|
+
// active first, main checkout first, then riskiest
|
|
131
|
+
group.checkouts.sort((a, b) => Number(b.active) - Number(a.active) ||
|
|
132
|
+
Number(a.worktree) - Number(b.worktree) ||
|
|
133
|
+
rank(a.worstSeverity) - rank(b.worstSeverity) ||
|
|
134
|
+
a.path.localeCompare(b.path));
|
|
112
135
|
return group;
|
|
113
136
|
})
|
|
114
|
-
.filter((g) => !opts.onlyVulnerable || g.vulnerablePackages > 0)
|
|
115
|
-
.sort((a, b) =>
|
|
137
|
+
.filter((g) => !opts.onlyVulnerable || g.checkouts.some((k) => k.vulnerablePackages > 0))
|
|
138
|
+
.sort((a, b) => Number(b.active) - Number(a.active) ||
|
|
139
|
+
rank(a.worstSeverity) - rank(b.worstSeverity) ||
|
|
140
|
+
b.vulnerablePackages - a.vulnerablePackages ||
|
|
141
|
+
a.label.localeCompare(b.label));
|
|
116
142
|
const projects = summaries
|
|
117
143
|
.filter((p) => !opts.onlyVulnerable || p.advisories > 0)
|
|
118
144
|
.sort((a, b) => rank(a.worstSeverity) - rank(b.worstSeverity) || b.advisories - a.advisories || a.path.localeCompare(b.path));
|
|
119
145
|
return { repos, projects };
|
|
120
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Input for reminders: per repo, the findings of the checkout that represents it — the main
|
|
149
|
+
* clone if it is active, otherwise the most recently active checkout. Feature worktrees
|
|
150
|
+
* branched from an old base get fixed when they merge, so they don't count twice.
|
|
151
|
+
*/
|
|
152
|
+
export function getAttention() {
|
|
153
|
+
const activeDays = loadConfig().activeDays;
|
|
154
|
+
const best = new Map();
|
|
155
|
+
for (const rec of listRepos()) {
|
|
156
|
+
if (!existsSync(rec.path))
|
|
157
|
+
continue;
|
|
158
|
+
const repo = repoIdentity(rec.path);
|
|
159
|
+
const act = activityOf(repo, activeDays);
|
|
160
|
+
if (!act.active)
|
|
161
|
+
continue;
|
|
162
|
+
const key = repo?.key ?? `path:${rec.path}`;
|
|
163
|
+
const cand = { rec, repo, main: !(repo?.worktree ?? false), at: act.lastActivity ? Date.parse(act.lastActivity) : 0 };
|
|
164
|
+
const cur = best.get(key);
|
|
165
|
+
if (!cur || (cand.main && !cur.main) || (cand.main === cur.main && cand.at > cur.at))
|
|
166
|
+
best.set(key, cand);
|
|
167
|
+
}
|
|
168
|
+
return [...best.entries()]
|
|
169
|
+
.filter(([, b]) => b.rec.findings.length > 0)
|
|
170
|
+
.map(([key, b]) => ({
|
|
171
|
+
key,
|
|
172
|
+
label: b.repo?.label ?? (b.rec.path.split("/").pop() || b.rec.path),
|
|
173
|
+
path: b.rec.path,
|
|
174
|
+
branch: b.repo?.branch ?? null,
|
|
175
|
+
findings: b.rec.findings,
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
/** All vulnerable packages of one repo group, merged across its checkouts (no network). */
|
|
179
|
+
export function getRepoDetail(key) {
|
|
180
|
+
const group = getStatus().repos.find((g) => g.key === key);
|
|
181
|
+
if (!group)
|
|
182
|
+
throw new ApiError("not_found", "unknown repo");
|
|
183
|
+
const byPkg = new Map();
|
|
184
|
+
for (const k of group.checkouts) {
|
|
185
|
+
const rec = loadRepo(k.path);
|
|
186
|
+
if (!rec)
|
|
187
|
+
continue;
|
|
188
|
+
for (const p of toReport(rec).vulnerable) {
|
|
189
|
+
const id = `${p.name}@${p.version}`;
|
|
190
|
+
let entry = byPkg.get(id);
|
|
191
|
+
if (!entry) {
|
|
192
|
+
entry = { ...p, checkouts: [] };
|
|
193
|
+
byPkg.set(id, entry);
|
|
194
|
+
}
|
|
195
|
+
entry.checkouts.push({ path: k.path, branch: k.branch, active: k.active });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const rank = (p) => (p.checkouts.some((c) => c.active) ? 0 : 1);
|
|
199
|
+
const packages = [...byPkg.values()].sort((a, b) => rank(a) - rank(b) ||
|
|
200
|
+
severityRank(a.worstSeverity) - severityRank(b.worstSeverity) ||
|
|
201
|
+
Number(a.dev) - Number(b.dev) ||
|
|
202
|
+
Number(b.direct) - Number(a.direct) ||
|
|
203
|
+
a.name.localeCompare(b.name));
|
|
204
|
+
return { key: group.key, label: group.label, checkouts: group.checkouts, packages };
|
|
205
|
+
}
|
|
121
206
|
/** Last stored report for a project (no network). */
|
|
122
207
|
export function getReport(path) {
|
|
123
208
|
const project = resolveProject(path);
|
|
@@ -126,7 +211,7 @@ export function getReport(path) {
|
|
|
126
211
|
throw new ApiError("not_tracked", `${project.root} is not tracked yet; scan it first`);
|
|
127
212
|
return toReport(rec);
|
|
128
213
|
}
|
|
129
|
-
export async function scanPath(path) {
|
|
214
|
+
export async function scanPath(path, opts = {}) {
|
|
130
215
|
const project = resolveProject(path);
|
|
131
216
|
const release = tryLock(`scan:${project.root}`);
|
|
132
217
|
if (!release)
|
|
@@ -134,13 +219,14 @@ export async function scanPath(path) {
|
|
|
134
219
|
try {
|
|
135
220
|
const [o] = await scanProjects([project]);
|
|
136
221
|
saveRepo(o.record);
|
|
222
|
+
await announce([o], { notify: opts.notify ?? false });
|
|
137
223
|
return { project: toReport(o.record), firstScan: o.firstScan, newAdvisories: o.newFindings.length };
|
|
138
224
|
}
|
|
139
225
|
finally {
|
|
140
226
|
release();
|
|
141
227
|
}
|
|
142
228
|
}
|
|
143
|
-
export async function sync() {
|
|
229
|
+
export async function sync(opts = {}) {
|
|
144
230
|
const release = tryLock("sync");
|
|
145
231
|
if (!release)
|
|
146
232
|
throw new ApiError("busy", "a sync is already running");
|
|
@@ -169,6 +255,8 @@ export async function sync() {
|
|
|
169
255
|
const outcomes = await scanProjects(projects);
|
|
170
256
|
for (const o of outcomes)
|
|
171
257
|
saveRepo(o.record);
|
|
258
|
+
await announce(outcomes, { notify: opts.notify ?? false });
|
|
259
|
+
updateState({ lastSync: new Date().toISOString() });
|
|
172
260
|
return {
|
|
173
261
|
projects: projects.length,
|
|
174
262
|
discovered: discovered.map((p) => p.root),
|
|
@@ -204,6 +292,7 @@ export async function discoverNew(opts = {}) {
|
|
|
204
292
|
const outcomes = await scanProjects(fresh);
|
|
205
293
|
for (const o of outcomes)
|
|
206
294
|
saveRepo(o.record);
|
|
295
|
+
await announce(outcomes, { notify: false });
|
|
207
296
|
return {
|
|
208
297
|
dryRun: false,
|
|
209
298
|
found,
|
|
@@ -282,6 +371,84 @@ export function unexclude(path) {
|
|
|
282
371
|
saveConfig(cfg);
|
|
283
372
|
return { roots: cfg.roots, exclude: cfg.exclude, removed: cfg.exclude.length < before };
|
|
284
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Re-scan only tracked projects whose lockfile changed (cheap stat first). Run by the
|
|
376
|
+
* lockfile watcher after `git pull`, installs or branch switches.
|
|
377
|
+
*/
|
|
378
|
+
export async function check(opts = {}) {
|
|
379
|
+
const release = tryLock("check");
|
|
380
|
+
if (!release)
|
|
381
|
+
throw new ApiError("busy", "a check is already running");
|
|
382
|
+
try {
|
|
383
|
+
const cfg = loadConfig();
|
|
384
|
+
const changed = [];
|
|
385
|
+
for (const r of listRepos()) {
|
|
386
|
+
if (!existsSync(r.path) || isExcluded(r.path, cfg.exclude))
|
|
387
|
+
continue;
|
|
388
|
+
const p = findProject(r.path);
|
|
389
|
+
if (p && p.root === r.path && p.lockfile && lockChanged(r, p))
|
|
390
|
+
changed.push(p);
|
|
391
|
+
}
|
|
392
|
+
updateState({ lastCheck: new Date().toISOString() });
|
|
393
|
+
if (changed.length === 0)
|
|
394
|
+
return { rescanned: [], errors: [] };
|
|
395
|
+
const outcomes = await scanProjects(changed);
|
|
396
|
+
for (const o of outcomes)
|
|
397
|
+
saveRepo(o.record);
|
|
398
|
+
await announce(outcomes, { notify: opts.notify ?? false });
|
|
399
|
+
return {
|
|
400
|
+
rescanned: outcomes.map((o) => o.record.path),
|
|
401
|
+
errors: outcomes.filter((o) => o.record.error).map((o) => ({ path: o.record.path, error: o.record.error })),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
release();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** Paths for the lockfile watcher: lockfile + folder of active checkouts, most recent first. */
|
|
409
|
+
export function watchTargets() {
|
|
410
|
+
const cfg = loadConfig();
|
|
411
|
+
const items = [];
|
|
412
|
+
for (const r of listRepos()) {
|
|
413
|
+
if (!existsSync(r.path) || isExcluded(r.path, cfg.exclude))
|
|
414
|
+
continue;
|
|
415
|
+
const act = activityOf(repoIdentity(r.path), cfg.activeDays);
|
|
416
|
+
if (!act.active)
|
|
417
|
+
continue;
|
|
418
|
+
const paths = [r.path];
|
|
419
|
+
if (r.lockfile && existsSync(r.lockfile))
|
|
420
|
+
paths.push(r.lockfile);
|
|
421
|
+
items.push({ paths, at: act.lastActivity ? Date.parse(act.lastActivity) : 0 });
|
|
422
|
+
}
|
|
423
|
+
return items.sort((a, b) => b.at - a.at).flatMap((i) => i.paths);
|
|
424
|
+
}
|
|
425
|
+
export function getSettings() {
|
|
426
|
+
const cfg = loadConfig();
|
|
427
|
+
const st = loadState();
|
|
428
|
+
return {
|
|
429
|
+
remind: cfg.remind,
|
|
430
|
+
activeDays: cfg.activeDays,
|
|
431
|
+
lastReminder: st.lastReminder ?? null,
|
|
432
|
+
lastSync: st.lastSync ?? null,
|
|
433
|
+
lastCheck: st.lastCheck ?? null,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
export function updateSettings(patch) {
|
|
437
|
+
const cfg = loadConfig();
|
|
438
|
+
if (patch.remind !== undefined) {
|
|
439
|
+
if (!REMIND_VALUES.includes(patch.remind))
|
|
440
|
+
throw new ApiError("invalid", "remind must be daily, weekly or off");
|
|
441
|
+
cfg.remind = patch.remind;
|
|
442
|
+
}
|
|
443
|
+
if (patch.activeDays !== undefined) {
|
|
444
|
+
const n = patch.activeDays;
|
|
445
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 365)
|
|
446
|
+
throw new ApiError("invalid", "activeDays must be an integer between 1 and 365");
|
|
447
|
+
cfg.activeDays = n;
|
|
448
|
+
}
|
|
449
|
+
saveConfig(cfg);
|
|
450
|
+
return getSettings();
|
|
451
|
+
}
|
|
285
452
|
export async function getVulnerability(id) {
|
|
286
453
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))
|
|
287
454
|
throw new ApiError("invalid", "invalid advisory id");
|
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import {
|
|
3
|
+
import { buildReminder, needsAttention } from "./alerts.js";
|
|
4
|
+
import { maybeRemind } from "./announce.js";
|
|
4
5
|
import * as api from "./api.js";
|
|
5
6
|
import { GUIDE } from "./guide.js";
|
|
6
7
|
import { hookEnter, ZSH_HOOK } from "./hook.js";
|
|
7
8
|
import { findProject } from "./lockfile/detect.js";
|
|
8
9
|
import { runMcpServer } from "./mcp.js";
|
|
9
|
-
import { paint, renderFindings, severityLabel, tildify
|
|
10
|
-
import { repoIdentity } from "./repoid.js";
|
|
10
|
+
import { groupByPackage, paint, renderFindings, severityLabel, tildify } from "./report.js";
|
|
11
11
|
import { clean } from "./sanitize.js";
|
|
12
|
-
import { parseInterval, scheduleOff, scheduleOn, scheduleStatus } from "./schedule.js";
|
|
12
|
+
import { parseInterval, scheduleOff, scheduleOn, scheduleStatus, updateWatcher } from "./schedule.js";
|
|
13
13
|
import { runSetup } from "./setup.js";
|
|
14
|
-
import {
|
|
14
|
+
import { loadRepo } from "./store.js";
|
|
15
15
|
import { isInteractive } from "./tui.js";
|
|
16
|
+
import { runUi } from "./ui/server.js";
|
|
16
17
|
import { VERSION } from "./version.js";
|
|
17
18
|
const HELP = `selfguard ${VERSION} — keeps an eye on the dependencies of every repo you work on
|
|
18
19
|
|
|
@@ -20,14 +21,25 @@ Setup:
|
|
|
20
21
|
selfguard setup interactive: choose the folders where your repos live
|
|
21
22
|
selfguard init zsh print the shell hook (eval "$(selfguard init zsh)" in ~/.zshrc)
|
|
22
23
|
selfguard schedule on [--every 6h] | off | status
|
|
23
|
-
|
|
24
|
+
background watching with macOS notifications (launchd):
|
|
25
|
+
lockfile changes right away, full sync every 6h
|
|
26
|
+
|
|
27
|
+
Dashboard and alerts:
|
|
28
|
+
selfguard ui [--no-open] [--port N]
|
|
29
|
+
local dashboard in your browser (127.0.0.1 only)
|
|
30
|
+
selfguard remind send the reminder of serious open issues now
|
|
31
|
+
selfguard settings [--remind daily|weekly|off] [--active-days N]
|
|
32
|
+
reminder frequency; days of git inactivity before a
|
|
33
|
+
checkout stops alerting (default 30)
|
|
24
34
|
|
|
25
35
|
Projects:
|
|
26
|
-
selfguard status [--vulnerable] [--flat]
|
|
27
|
-
tracked repos, riskiest first (worktrees/clones grouped
|
|
36
|
+
selfguard status [--vulnerable] [--flat] [--all]
|
|
37
|
+
tracked repos, riskiest first (worktrees/clones grouped;
|
|
38
|
+
inactive checkouts hidden unless --all)
|
|
28
39
|
selfguard scan [dir] [-v] scan a project now (exit 1 if vulnerable) and track it
|
|
29
40
|
selfguard report [dir] [-v] last scan results, no network
|
|
30
41
|
selfguard sync find new repos in your folders + re-check everything
|
|
42
|
+
selfguard check re-scan only projects whose lockfile changed
|
|
31
43
|
selfguard where <package> which projects use <package>, which versions
|
|
32
44
|
selfguard vuln <id> details of an advisory and which projects it affects
|
|
33
45
|
|
|
@@ -59,20 +71,11 @@ function emit(data) {
|
|
|
59
71
|
function usage(message) {
|
|
60
72
|
throw new UsageError(message);
|
|
61
73
|
}
|
|
62
|
-
function notifyNew(outcomes) {
|
|
63
|
-
return deliver(buildAlert(outcomes
|
|
64
|
-
.filter((o) => o.newFindings.length > 0)
|
|
65
|
-
.map((o) => ({ path: o.record.path, newFindings: o.newFindings, repo: repoIdentity(o.record.path) }))));
|
|
66
|
-
}
|
|
67
74
|
/** Detached scan started by the shell hook: log + notify, never print to a terminal. */
|
|
68
75
|
async function backgroundScan(path) {
|
|
69
76
|
const stamp = new Date().toISOString();
|
|
70
77
|
try {
|
|
71
|
-
const
|
|
72
|
-
const before = new Set((project ? loadRepo(project.root)?.findings ?? [] : []).map(findingKey));
|
|
73
|
-
const r = await api.scanPath(path);
|
|
74
|
-
const rec = loadRepo(r.project.path);
|
|
75
|
-
await notifyNew([{ record: rec, newFindings: rec.findings.filter((f) => !before.has(findingKey(f))), firstScan: r.firstScan }]);
|
|
78
|
+
const r = await api.scanPath(path, { notify: true });
|
|
76
79
|
out.write(`${stamp} scan ${r.project.path}: ${r.project.advisories} finding(s)${r.project.error ? ` (${r.project.error})` : ""}\n`);
|
|
77
80
|
}
|
|
78
81
|
catch (e) {
|
|
@@ -111,7 +114,10 @@ function cmdReport(args) {
|
|
|
111
114
|
return report.advisories > 0 ? 1 : 0;
|
|
112
115
|
}
|
|
113
116
|
function cmdStatus(args) {
|
|
114
|
-
const { values } = parseArgs({
|
|
117
|
+
const { values } = parseArgs({
|
|
118
|
+
args,
|
|
119
|
+
options: { ...JSON_OPT, vulnerable: { type: "boolean" }, flat: { type: "boolean" }, all: { type: "boolean" } },
|
|
120
|
+
});
|
|
115
121
|
const status = api.getStatus({ onlyVulnerable: values.vulnerable ?? false });
|
|
116
122
|
if (values.json) {
|
|
117
123
|
emit(status);
|
|
@@ -135,7 +141,16 @@ function cmdStatus(args) {
|
|
|
135
141
|
}
|
|
136
142
|
return 0;
|
|
137
143
|
}
|
|
138
|
-
|
|
144
|
+
const showAll = values.all ?? false;
|
|
145
|
+
let hiddenRepos = 0;
|
|
146
|
+
for (const g0 of status.repos) {
|
|
147
|
+
if (!g0.active && !showAll) {
|
|
148
|
+
hiddenRepos++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const visible = showAll ? g0.checkouts : g0.checkouts.filter((k) => k.active);
|
|
152
|
+
const hiddenCheckouts = g0.checkouts.length - visible.length;
|
|
153
|
+
const g = { ...g0, checkouts: visible };
|
|
139
154
|
const many = g.checkouts.length > 1;
|
|
140
155
|
const firstError = g.checkouts.find((k) => k.error)?.error ?? null;
|
|
141
156
|
// Headline: the main checkout; mention a branch that is worse than it.
|
|
@@ -152,31 +167,46 @@ function cmdStatus(args) {
|
|
|
152
167
|
: c.dim("clean");
|
|
153
168
|
const head = worse ? `${mainText} ${c.yellow(`· ${worse.vulnerablePackages} on ${clean(worse.branch ?? "another checkout", 40)}`)}` : mainText;
|
|
154
169
|
const where = many ? c.dim(`${g.checkouts.length} checkouts`) : c.dim(tildify(g.checkouts[0].path));
|
|
155
|
-
|
|
170
|
+
const more = hiddenCheckouts ? c.dim(` +${hiddenCheckouts} inactive`) : "";
|
|
171
|
+
const dimIf = (s) => (g.active ? s : c.dim(s));
|
|
172
|
+
out.write(`${badge(g.worstSeverity, firstError)} ${c.bold(dimIf(clean(g.label, 80)))} ${head} ${where}${more}\n`);
|
|
156
173
|
if (!many)
|
|
157
174
|
continue;
|
|
158
175
|
g.checkouts.forEach((k, i) => {
|
|
159
176
|
const branch = k.branch ? c.cyan(clean(k.branch, 50)) : c.dim("(no branch)");
|
|
160
|
-
const kind = k.worktree ? c.dim(" worktree") : "";
|
|
177
|
+
const kind = (k.worktree ? c.dim(" worktree") : "") + (k.active ? "" : c.dim(` inactive${k.lastActivity ? ` ${ago(k.lastActivity)}` : ""}`));
|
|
161
178
|
const detail = k.advisories ? `${k.vulnerablePackages} vulnerable` : k.error ? c.dim(clean(k.error, 60)) : c.dim("clean");
|
|
162
179
|
const tree = i === g.checkouts.length - 1 ? "└" : "├";
|
|
163
180
|
out.write(` ${c.dim(tree)} ${branch}${kind} ${detail} ${c.dim(tildify(k.path))}${k.exists ? "" : c.dim(" (missing)")}\n`);
|
|
164
181
|
});
|
|
165
182
|
}
|
|
183
|
+
if (hiddenRepos)
|
|
184
|
+
out.write(c.dim(`${hiddenRepos} inactive repo(s) hidden (no git activity in ${api.getSettings().activeDays} days). Show them with --all.\n`));
|
|
166
185
|
return 0;
|
|
167
186
|
}
|
|
187
|
+
function ago(iso) {
|
|
188
|
+
const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000);
|
|
189
|
+
return days < 1 ? "today" : days < 60 ? `${days}d ago` : `${Math.floor(days / 30)}mo ago`;
|
|
190
|
+
}
|
|
168
191
|
async function cmdSync(args) {
|
|
169
192
|
const { values } = parseArgs({ args, options: { ...JSON_OPT, background: { type: "boolean" } } });
|
|
170
193
|
let r;
|
|
171
194
|
try {
|
|
172
|
-
r = await api.sync();
|
|
195
|
+
r = await api.sync({ notify: true });
|
|
173
196
|
}
|
|
174
197
|
catch (e) {
|
|
175
198
|
if (values.background && e instanceof api.ApiError && e.code === "busy")
|
|
176
199
|
return 0;
|
|
177
200
|
throw e;
|
|
178
201
|
}
|
|
179
|
-
|
|
202
|
+
try {
|
|
203
|
+
updateWatcher(api.watchTargets());
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
err.write(`selfguard: could not update the lockfile watcher: ${clean(e.message, 200)}\n`);
|
|
207
|
+
}
|
|
208
|
+
if (values.background)
|
|
209
|
+
await maybeRemind(api.getAttention);
|
|
180
210
|
const { outcomes: _o, ...data } = r;
|
|
181
211
|
const total = data.news.reduce((n, p) => n + p.packages.length, 0);
|
|
182
212
|
if (values.json)
|
|
@@ -200,6 +230,93 @@ async function cmdSync(args) {
|
|
|
200
230
|
}
|
|
201
231
|
return 0;
|
|
202
232
|
}
|
|
233
|
+
async function cmdCheck(args) {
|
|
234
|
+
const { values } = parseArgs({ args, options: { ...JSON_OPT, background: { type: "boolean" } } });
|
|
235
|
+
let r;
|
|
236
|
+
try {
|
|
237
|
+
r = await api.check({ notify: true });
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
if (values.background && e instanceof api.ApiError && e.code === "busy")
|
|
241
|
+
return 0;
|
|
242
|
+
throw e;
|
|
243
|
+
}
|
|
244
|
+
if (values.background) {
|
|
245
|
+
if (r.rescanned.length)
|
|
246
|
+
out.write(`${new Date().toISOString()} check: re-scanned ${r.rescanned.join(", ")}\n`);
|
|
247
|
+
await maybeRemind(api.getAttention);
|
|
248
|
+
}
|
|
249
|
+
else if (values.json)
|
|
250
|
+
emit(r);
|
|
251
|
+
else
|
|
252
|
+
out.write(r.rescanned.length ? `Re-scanned ${r.rescanned.length} project(s) with changed lockfiles.\n` : "No lockfile changed.\n");
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
async function cmdRemind(args) {
|
|
256
|
+
const { values } = parseArgs({ args, options: { ...JSON_OPT, "dry-run": { type: "boolean" } } });
|
|
257
|
+
const repos = api.getAttention();
|
|
258
|
+
const alert = buildReminder(repos);
|
|
259
|
+
const sent = values["dry-run"] ? false : await maybeRemind(repos, { force: true });
|
|
260
|
+
const serious = repos
|
|
261
|
+
.map((r) => ({ repo: r.label, packages: groupByPackage(r.findings.filter(needsAttention)) }))
|
|
262
|
+
.filter((r) => r.packages.length > 0);
|
|
263
|
+
if (values.json) {
|
|
264
|
+
emit({
|
|
265
|
+
sent,
|
|
266
|
+
notification: alert.kind === "none" ? null : { title: alert.title, subtitle: alert.subtitle, body: alert.body },
|
|
267
|
+
repos: serious.map((r) => ({
|
|
268
|
+
repo: r.repo,
|
|
269
|
+
packages: r.packages.map((g) => ({ name: g.name, version: g.version, worstSeverity: g.worst, upgradeTo: g.upgradeTo ?? null, direct: g.direct })),
|
|
270
|
+
})),
|
|
271
|
+
});
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
const c = paint(out);
|
|
275
|
+
if (serious.length === 0) {
|
|
276
|
+
out.write(`${c.green("✓")} Nothing serious open in your active repos (malicious packages, critical vulnerabilities, or high ones in direct dependencies).\n`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
for (const r of serious) {
|
|
280
|
+
out.write(`${c.bold(clean(r.repo, 80))}\n`);
|
|
281
|
+
for (const g of r.packages.slice(0, 6)) {
|
|
282
|
+
const fix = g.worst === "malicious" ? c.red("remove it") : g.upgradeTo ? c.green(`→ ${clean(g.upgradeTo, 40)}`) : c.yellow("no fix yet");
|
|
283
|
+
out.write(` ${severityLabel(g.worst, c)} ${clean(g.name, 100)}@${clean(g.version, 40)} ${c.dim(g.direct ? "direct" : "transitive")} ${fix}\n`);
|
|
284
|
+
}
|
|
285
|
+
if (r.packages.length > 6)
|
|
286
|
+
out.write(c.dim(` …and ${r.packages.length - 6} more (selfguard ui)\n`));
|
|
287
|
+
}
|
|
288
|
+
out.write(c.dim(sent ? "\nNotification sent.\n" : "\n"));
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
function cmdSettings(args) {
|
|
292
|
+
const { values } = parseArgs({ args, options: { ...JSON_OPT, remind: { type: "string" }, "active-days": { type: "string" } } });
|
|
293
|
+
const patch = {};
|
|
294
|
+
if (values.remind !== undefined)
|
|
295
|
+
patch.remind = values.remind;
|
|
296
|
+
if (values["active-days"] !== undefined)
|
|
297
|
+
patch.activeDays = Number(values["active-days"]);
|
|
298
|
+
const s = Object.keys(patch).length ? api.updateSettings(patch) : api.getSettings();
|
|
299
|
+
if (values.json)
|
|
300
|
+
emit(s);
|
|
301
|
+
else {
|
|
302
|
+
out.write(`remind ${s.remind}${s.lastReminder ? paint(out).dim(` (last ${s.lastReminder})`) : ""}\n`);
|
|
303
|
+
out.write(`active-days ${s.activeDays}\n`);
|
|
304
|
+
if (s.lastSync)
|
|
305
|
+
out.write(paint(out).dim(`last sync ${s.lastSync}\n`));
|
|
306
|
+
}
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
async function cmdUi(args) {
|
|
310
|
+
const { values } = parseArgs({
|
|
311
|
+
args,
|
|
312
|
+
options: { "no-open": { type: "boolean" }, port: { type: "string" }, serve: { type: "boolean" } },
|
|
313
|
+
});
|
|
314
|
+
const port = values.port === undefined ? 0 : Number(values.port);
|
|
315
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
316
|
+
usage("--port must be a port number");
|
|
317
|
+
await runUi({ port, open: !values["no-open"], foreground: values.serve ?? false, log: (line) => out.write(`${line}\n`) });
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
203
320
|
async function cmdDiscover(args) {
|
|
204
321
|
const { values } = parseArgs({ args, options: { ...JSON_OPT, "dry-run": { type: "boolean" } } });
|
|
205
322
|
const r = await api.discoverNew({ dryRun: values["dry-run"] ?? false });
|
|
@@ -347,11 +464,12 @@ function cmdSchedule(args) {
|
|
|
347
464
|
switch (positionals[0]) {
|
|
348
465
|
case "on": {
|
|
349
466
|
const seconds = parseInterval(values.every ?? "6h");
|
|
350
|
-
const
|
|
467
|
+
const targets = api.watchTargets();
|
|
468
|
+
const plist = scheduleOn(seconds, targets);
|
|
351
469
|
if (values.json)
|
|
352
|
-
emit({ enabled: true, intervalSeconds: seconds, plist, node: process.execPath });
|
|
470
|
+
emit({ enabled: true, intervalSeconds: seconds, plist, node: process.execPath, watchedPaths: targets.length });
|
|
353
471
|
else {
|
|
354
|
-
out.write(`Background sync every ${values.every}. (${tildify(plist)})\n`);
|
|
472
|
+
out.write(`Background sync every ${values.every}, plus instant checks when a lockfile changes. (${tildify(plist)})\n`);
|
|
355
473
|
out.write(`Note: it runs with ${process.execPath}; re-run this if you change Node versions.\n`);
|
|
356
474
|
}
|
|
357
475
|
return 0;
|
|
@@ -361,7 +479,7 @@ function cmdSchedule(args) {
|
|
|
361
479
|
if (values.json)
|
|
362
480
|
emit({ enabled: false, wasEnabled: was });
|
|
363
481
|
else
|
|
364
|
-
out.write(was ? "Background sync disabled.\n" : "Background sync was not enabled.\n");
|
|
482
|
+
out.write(was ? "Background sync and lockfile watcher disabled.\n" : "Background sync was not enabled.\n");
|
|
365
483
|
return 0;
|
|
366
484
|
}
|
|
367
485
|
case "status":
|
|
@@ -369,8 +487,11 @@ function cmdSchedule(args) {
|
|
|
369
487
|
const s = scheduleStatus();
|
|
370
488
|
if (values.json)
|
|
371
489
|
emit(s);
|
|
372
|
-
else
|
|
490
|
+
else {
|
|
373
491
|
out.write(s.loaded ? `enabled (${tildify(s.plist)})\n` : s.installed ? "installed but not loaded\n" : "disabled\n");
|
|
492
|
+
if (s.loaded)
|
|
493
|
+
out.write(s.watcher.loaded ? `lockfile watcher: ${s.watcher.paths} path(s)\n` : "lockfile watcher: not running (run selfguard sync)\n");
|
|
494
|
+
}
|
|
374
495
|
return 0;
|
|
375
496
|
}
|
|
376
497
|
default:
|
|
@@ -402,6 +523,14 @@ async function main(argv) {
|
|
|
402
523
|
return cmdStatus(rest);
|
|
403
524
|
case "sync":
|
|
404
525
|
return cmdSync(rest);
|
|
526
|
+
case "check":
|
|
527
|
+
return cmdCheck(rest);
|
|
528
|
+
case "remind":
|
|
529
|
+
return cmdRemind(rest);
|
|
530
|
+
case "settings":
|
|
531
|
+
return cmdSettings(rest);
|
|
532
|
+
case "ui":
|
|
533
|
+
return cmdUi(rest);
|
|
405
534
|
case "discover":
|
|
406
535
|
return cmdDiscover(rest);
|
|
407
536
|
case "roots":
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,8 @@ import { atomicWrite, isObject, readJsonFile } from "./fsutil.js";
|
|
|
5
5
|
import { stateRoot } from "./paths.js";
|
|
6
6
|
export const DEFAULT_DEPTH = 5;
|
|
7
7
|
export const MAX_DEPTH = 10;
|
|
8
|
+
export const DEFAULT_ACTIVE_DAYS = 30;
|
|
9
|
+
export const REMIND_VALUES = ["daily", "weekly", "off"];
|
|
8
10
|
const configFile = () => join(stateRoot(), "config.json");
|
|
9
11
|
export function loadConfig() {
|
|
10
12
|
const raw = readJsonFile(configFile());
|
|
@@ -18,7 +20,11 @@ export function loadConfig() {
|
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
const exclude = raw && isObject(raw) && Array.isArray(raw.exclude) ? raw.exclude.filter((e) => typeof e === "string") : [];
|
|
21
|
-
|
|
23
|
+
const remind = raw && isObject(raw) && REMIND_VALUES.includes(raw.remind) ? raw.remind : "daily";
|
|
24
|
+
const activeDays = raw && isObject(raw) && typeof raw.activeDays === "number" && raw.activeDays >= 1
|
|
25
|
+
? Math.min(365, Math.trunc(raw.activeDays))
|
|
26
|
+
: DEFAULT_ACTIVE_DAYS;
|
|
27
|
+
return { schema: 1, roots, exclude, remind, activeDays };
|
|
22
28
|
}
|
|
23
29
|
export function isExcluded(path, exclude) {
|
|
24
30
|
return exclude.some((e) => path === e || path.startsWith(e.endsWith("/") ? e : `${e}/`));
|