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
package/dist/api.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured operations shared by the CLI (`--json`) and the MCP server.
|
|
3
|
+
* Every function returns plain JSON-serializable data with a stable shape
|
|
4
|
+
* (documented in src/guide.ts). Human rendering lives elsewhere.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
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";
|
|
11
|
+
import { discoverAll } from "./discover.js";
|
|
12
|
+
import { tryLock } from "./fsutil.js";
|
|
13
|
+
import { findProject } from "./lockfile/detect.js";
|
|
14
|
+
import { evaluate, severityOf, severityRank } from "./match.js";
|
|
15
|
+
import { getVulns } from "./osv.js";
|
|
16
|
+
import { countBySeverity, groupByPackage, worstSeverity } from "./report.js";
|
|
17
|
+
import { repoIdentity } from "./repoid.js";
|
|
18
|
+
import { clean } from "./sanitize.js";
|
|
19
|
+
import { lockChanged, scanProjects } from "./scan.js";
|
|
20
|
+
import { loadState, updateState } from "./state.js";
|
|
21
|
+
import { forgetRepo, listRepos, loadRepo, saveRepo } from "./store.js";
|
|
22
|
+
export class ApiError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const advisoryUrl = (id) => `https://osv.dev/vulnerability/${encodeURIComponent(id)}`;
|
|
30
|
+
// ---------- helpers ----------
|
|
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 };
|
|
35
|
+
return {
|
|
36
|
+
path: rec.path,
|
|
37
|
+
exists,
|
|
38
|
+
lockfile: rec.lockfile,
|
|
39
|
+
lockType: rec.lockType,
|
|
40
|
+
packages: rec.deps.length,
|
|
41
|
+
skippedEntries: rec.skipped,
|
|
42
|
+
scannedAt: rec.scannedAt,
|
|
43
|
+
error: rec.error ?? null,
|
|
44
|
+
worstSeverity: worstSeverity(rec.findings),
|
|
45
|
+
vulnerablePackages: groupByPackage(rec.findings).length,
|
|
46
|
+
advisories: rec.findings.length,
|
|
47
|
+
bySeverity: countBySeverity(rec.findings),
|
|
48
|
+
repo,
|
|
49
|
+
active: activity.active,
|
|
50
|
+
lastActivity: activity.lastActivity,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function toReport(rec) {
|
|
54
|
+
return {
|
|
55
|
+
...summarize(rec),
|
|
56
|
+
vulnerable: groupByPackage(rec.findings).map((g) => ({
|
|
57
|
+
name: g.name,
|
|
58
|
+
version: g.version,
|
|
59
|
+
direct: g.direct,
|
|
60
|
+
dev: g.dev,
|
|
61
|
+
worstSeverity: g.worst,
|
|
62
|
+
upgradeTo: g.worst === "malicious" ? null : (g.upgradeTo ?? null),
|
|
63
|
+
advisoriesWithoutFix: g.unfixed,
|
|
64
|
+
advisories: g.findings.map((f) => ({
|
|
65
|
+
id: f.id,
|
|
66
|
+
aliases: f.aliases,
|
|
67
|
+
severity: f.severity,
|
|
68
|
+
summary: f.summary,
|
|
69
|
+
fixedIn: f.fixedIn ?? null,
|
|
70
|
+
firstSeen: f.firstSeen,
|
|
71
|
+
url: advisoryUrl(f.id),
|
|
72
|
+
})),
|
|
73
|
+
})),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function resolveProject(path) {
|
|
77
|
+
const abs = resolve(path);
|
|
78
|
+
if (!existsSync(abs))
|
|
79
|
+
throw new ApiError("not_found", `no such directory: ${abs}`);
|
|
80
|
+
const project = findProject(abs);
|
|
81
|
+
if (!project)
|
|
82
|
+
throw new ApiError("no_project", `no package.json found in ${abs} or its parents`);
|
|
83
|
+
return project;
|
|
84
|
+
}
|
|
85
|
+
// ---------- operations ----------
|
|
86
|
+
export function getStatus(opts = {}) {
|
|
87
|
+
const rank = (w) => (w ? severityRank(w) : 99);
|
|
88
|
+
const records = listRepos();
|
|
89
|
+
const activeDays = loadConfig().activeDays;
|
|
90
|
+
const summaries = records.map((r) => summarize(r, activeDays));
|
|
91
|
+
const groups = new Map();
|
|
92
|
+
summaries.forEach((p, i) => {
|
|
93
|
+
const rec = records[i];
|
|
94
|
+
const key = p.repo?.key ?? `path:${p.path}`;
|
|
95
|
+
let g = groups.get(key);
|
|
96
|
+
if (!g) {
|
|
97
|
+
const label = p.repo?.label ?? (p.path.split("/").pop() || p.path);
|
|
98
|
+
g = {
|
|
99
|
+
group: { key, label, active: false, worstSeverity: null, vulnerablePackages: 0, inactiveCheckouts: 0, checkouts: [] },
|
|
100
|
+
active: [],
|
|
101
|
+
all: [],
|
|
102
|
+
};
|
|
103
|
+
groups.set(key, g);
|
|
104
|
+
}
|
|
105
|
+
g.group.checkouts.push({
|
|
106
|
+
path: p.path,
|
|
107
|
+
branch: p.repo?.branch ?? null,
|
|
108
|
+
worktree: p.repo?.worktree ?? false,
|
|
109
|
+
orphaned: p.repo?.orphaned ?? false,
|
|
110
|
+
exists: p.exists,
|
|
111
|
+
active: p.active,
|
|
112
|
+
lastActivity: p.lastActivity,
|
|
113
|
+
worstSeverity: p.worstSeverity,
|
|
114
|
+
vulnerablePackages: p.vulnerablePackages,
|
|
115
|
+
advisories: p.advisories,
|
|
116
|
+
error: p.error,
|
|
117
|
+
});
|
|
118
|
+
if (p.active)
|
|
119
|
+
g.active.push(...rec.findings);
|
|
120
|
+
else
|
|
121
|
+
g.group.inactiveCheckouts++;
|
|
122
|
+
g.all.push(...rec.findings);
|
|
123
|
+
});
|
|
124
|
+
const repos = [...groups.values()]
|
|
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));
|
|
135
|
+
return group;
|
|
136
|
+
})
|
|
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));
|
|
142
|
+
const projects = summaries
|
|
143
|
+
.filter((p) => !opts.onlyVulnerable || p.advisories > 0)
|
|
144
|
+
.sort((a, b) => rank(a.worstSeverity) - rank(b.worstSeverity) || b.advisories - a.advisories || a.path.localeCompare(b.path));
|
|
145
|
+
return { repos, projects };
|
|
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
|
+
}
|
|
206
|
+
/** Last stored report for a project (no network). */
|
|
207
|
+
export function getReport(path) {
|
|
208
|
+
const project = resolveProject(path);
|
|
209
|
+
const rec = loadRepo(project.root);
|
|
210
|
+
if (!rec)
|
|
211
|
+
throw new ApiError("not_tracked", `${project.root} is not tracked yet; scan it first`);
|
|
212
|
+
return toReport(rec);
|
|
213
|
+
}
|
|
214
|
+
export async function scanPath(path, opts = {}) {
|
|
215
|
+
const project = resolveProject(path);
|
|
216
|
+
const release = tryLock(`scan:${project.root}`);
|
|
217
|
+
if (!release)
|
|
218
|
+
throw new ApiError("busy", "another scan of this project is running, try again in a moment");
|
|
219
|
+
try {
|
|
220
|
+
const [o] = await scanProjects([project]);
|
|
221
|
+
saveRepo(o.record);
|
|
222
|
+
await announce([o], { notify: opts.notify ?? false });
|
|
223
|
+
return { project: toReport(o.record), firstScan: o.firstScan, newAdvisories: o.newFindings.length };
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
release();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export async function sync(opts = {}) {
|
|
230
|
+
const release = tryLock("sync");
|
|
231
|
+
if (!release)
|
|
232
|
+
throw new ApiError("busy", "a sync is already running");
|
|
233
|
+
try {
|
|
234
|
+
const cfg = loadConfig();
|
|
235
|
+
const projects = [];
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
const pruned = [];
|
|
238
|
+
for (const r of listRepos()) {
|
|
239
|
+
if (!existsSync(r.path)) {
|
|
240
|
+
forgetRepo(r.path); // e.g. a worktree you removed: nothing left to protect
|
|
241
|
+
pruned.push(r.path);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (isExcluded(r.path, cfg.exclude))
|
|
245
|
+
continue;
|
|
246
|
+
const p = findProject(r.path);
|
|
247
|
+
if (p && p.root === r.path) {
|
|
248
|
+
projects.push(p);
|
|
249
|
+
seen.add(p.root);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// New clones inside your folders get tracked without you ever cd-ing into them.
|
|
253
|
+
const discovered = (await discoverAll(cfg.roots, { exclude: cfg.exclude })).filter((p) => !seen.has(p.root));
|
|
254
|
+
projects.push(...discovered);
|
|
255
|
+
const outcomes = await scanProjects(projects);
|
|
256
|
+
for (const o of outcomes)
|
|
257
|
+
saveRepo(o.record);
|
|
258
|
+
await announce(outcomes, { notify: opts.notify ?? false });
|
|
259
|
+
updateState({ lastSync: new Date().toISOString() });
|
|
260
|
+
return {
|
|
261
|
+
projects: projects.length,
|
|
262
|
+
discovered: discovered.map((p) => p.root),
|
|
263
|
+
pruned,
|
|
264
|
+
news: outcomes
|
|
265
|
+
.filter((o) => o.newFindings.length > 0)
|
|
266
|
+
.map((o) => ({
|
|
267
|
+
path: o.record.path,
|
|
268
|
+
packages: groupByPackage(o.newFindings).map((g) => ({
|
|
269
|
+
name: g.name,
|
|
270
|
+
version: g.version,
|
|
271
|
+
worstSeverity: g.worst,
|
|
272
|
+
advisories: g.findings.map((f) => f.id),
|
|
273
|
+
})),
|
|
274
|
+
})),
|
|
275
|
+
errors: outcomes.filter((o) => o.record.error).map((o) => ({ path: o.record.path, error: o.record.error })),
|
|
276
|
+
outcomes,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
release();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export async function discoverNew(opts = {}) {
|
|
284
|
+
const cfg = loadConfig();
|
|
285
|
+
if (cfg.roots.length === 0)
|
|
286
|
+
throw new ApiError("no_roots", "no folders configured; add one with roots add <dir>");
|
|
287
|
+
const known = new Set(listRepos().map((r) => r.path));
|
|
288
|
+
const fresh = (await discoverAll(cfg.roots, { exclude: cfg.exclude })).filter((p) => !known.has(p.root));
|
|
289
|
+
const found = fresh.map((p) => ({ path: p.root, lockType: p.lockType }));
|
|
290
|
+
if (opts.dryRun || fresh.length === 0)
|
|
291
|
+
return { dryRun: Boolean(opts.dryRun), found, registered: 0, withVulnerabilities: 0 };
|
|
292
|
+
const outcomes = await scanProjects(fresh);
|
|
293
|
+
for (const o of outcomes)
|
|
294
|
+
saveRepo(o.record);
|
|
295
|
+
await announce(outcomes, { notify: false });
|
|
296
|
+
return {
|
|
297
|
+
dryRun: false,
|
|
298
|
+
found,
|
|
299
|
+
registered: outcomes.length,
|
|
300
|
+
withVulnerabilities: outcomes.filter((o) => o.record.findings.length > 0).length,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
export function whereUsed(name) {
|
|
304
|
+
const usedIn = [];
|
|
305
|
+
for (const r of listRepos()) {
|
|
306
|
+
const versions = r.deps
|
|
307
|
+
.filter((d) => d.name === name)
|
|
308
|
+
.map((d) => ({
|
|
309
|
+
version: d.version,
|
|
310
|
+
direct: d.direct,
|
|
311
|
+
dev: d.dev,
|
|
312
|
+
vulnerable: r.findings.some((f) => f.name === name && f.version === d.version),
|
|
313
|
+
}));
|
|
314
|
+
if (versions.length)
|
|
315
|
+
usedIn.push({ path: r.path, versions });
|
|
316
|
+
}
|
|
317
|
+
return { package: name, usedIn };
|
|
318
|
+
}
|
|
319
|
+
export function listRoots() {
|
|
320
|
+
const { roots, exclude } = loadConfig();
|
|
321
|
+
return { roots, exclude };
|
|
322
|
+
}
|
|
323
|
+
export function addRoot(path, depth) {
|
|
324
|
+
const dir = normalizeDir(path);
|
|
325
|
+
if (!dir)
|
|
326
|
+
throw new ApiError("not_found", `not a directory: ${path}`);
|
|
327
|
+
const cfg = loadConfig();
|
|
328
|
+
const d = depth === undefined ? DEFAULT_DEPTH : clampDepth(depth);
|
|
329
|
+
cfg.roots = [...cfg.roots.filter((r) => r.path !== dir), { path: dir, depth: d }];
|
|
330
|
+
saveConfig(cfg);
|
|
331
|
+
return { roots: cfg.roots, exclude: cfg.exclude };
|
|
332
|
+
}
|
|
333
|
+
export function removeRoot(path) {
|
|
334
|
+
const cfg = loadConfig();
|
|
335
|
+
const abs = normalizeDir(path) ?? resolve(path);
|
|
336
|
+
const before = cfg.roots.length;
|
|
337
|
+
cfg.roots = cfg.roots.filter((r) => r.path !== abs);
|
|
338
|
+
saveConfig(cfg);
|
|
339
|
+
return { roots: cfg.roots, exclude: cfg.exclude, removed: cfg.roots.length < before };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Stop tracking a path. With `exclude` (default), it's also excluded from discovery
|
|
343
|
+
* so it never comes back (use for fixtures, examples, archived repos).
|
|
344
|
+
*/
|
|
345
|
+
export function forget(path, opts = {}) {
|
|
346
|
+
const abs = normalizeDir(path) ?? resolve(path);
|
|
347
|
+
const root = loadRepo(abs) ? abs : (findProject(abs)?.root ?? abs);
|
|
348
|
+
const wasTracked = forgetRepo(root);
|
|
349
|
+
const alsoForgotten = [];
|
|
350
|
+
const exclude = opts.exclude ?? true;
|
|
351
|
+
if (exclude) {
|
|
352
|
+
const cfg = loadConfig();
|
|
353
|
+
if (!cfg.exclude.includes(root))
|
|
354
|
+
cfg.exclude.push(root);
|
|
355
|
+
saveConfig(cfg);
|
|
356
|
+
// Excluding a folder also drops tracked projects below it.
|
|
357
|
+
for (const r of listRepos()) {
|
|
358
|
+
if (r.path !== root && isExcluded(r.path, [root])) {
|
|
359
|
+
forgetRepo(r.path);
|
|
360
|
+
alsoForgotten.push(r.path);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { path: root, wasTracked, excluded: exclude, alsoForgotten };
|
|
365
|
+
}
|
|
366
|
+
export function unexclude(path) {
|
|
367
|
+
const cfg = loadConfig();
|
|
368
|
+
const abs = normalizeDir(path) ?? resolve(path);
|
|
369
|
+
const before = cfg.exclude.length;
|
|
370
|
+
cfg.exclude = cfg.exclude.filter((e) => e !== abs);
|
|
371
|
+
saveConfig(cfg);
|
|
372
|
+
return { roots: cfg.roots, exclude: cfg.exclude, removed: cfg.exclude.length < before };
|
|
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
|
+
}
|
|
452
|
+
export async function getVulnerability(id) {
|
|
453
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))
|
|
454
|
+
throw new ApiError("invalid", "invalid advisory id");
|
|
455
|
+
const vulns = await getVulns([{ id, modified: "" }]);
|
|
456
|
+
const v = vulns.get(id);
|
|
457
|
+
if (!v)
|
|
458
|
+
throw new ApiError("not_found", `advisory ${id} not found`);
|
|
459
|
+
const s = (x, n) => clean(x ?? "", n);
|
|
460
|
+
const affectsYourProjects = [];
|
|
461
|
+
for (const r of listRepos()) {
|
|
462
|
+
for (const d of r.deps) {
|
|
463
|
+
const ev = evaluate(v, d.name, d.version);
|
|
464
|
+
if (ev.affected)
|
|
465
|
+
affectsYourProjects.push({ path: r.path, package: d.name, version: d.version, fixedIn: ev.fixedIn ?? null });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const raw = v;
|
|
469
|
+
return {
|
|
470
|
+
id: v.id,
|
|
471
|
+
aliases: (v.aliases ?? []).map((a) => s(a, 64)),
|
|
472
|
+
severity: severityOf(v),
|
|
473
|
+
summary: s(v.summary, 300),
|
|
474
|
+
details: clean(v.details ?? "", 4000),
|
|
475
|
+
published: typeof raw.published === "string" ? raw.published : null,
|
|
476
|
+
modified: v.modified ?? null,
|
|
477
|
+
withdrawn: v.withdrawn ?? null,
|
|
478
|
+
affected: (v.affected ?? [])
|
|
479
|
+
.filter((a) => a.package?.ecosystem === "npm")
|
|
480
|
+
.slice(0, 20)
|
|
481
|
+
.map((a) => ({
|
|
482
|
+
package: s(a.package?.name, 214),
|
|
483
|
+
ranges: (a.ranges ?? []).flatMap((r) => (r.events ?? []).map((e) => ({ ...e }))).slice(0, 50),
|
|
484
|
+
versions: (a.versions ?? []).slice(0, 50).map((x) => s(x, 64)),
|
|
485
|
+
})),
|
|
486
|
+
references: (Array.isArray(raw.references) ? raw.references : [])
|
|
487
|
+
.map((r) => (r && typeof r === "object" ? r.url : undefined))
|
|
488
|
+
.filter((u) => typeof u === "string" && /^https:\/\//.test(u))
|
|
489
|
+
.slice(0, 15)
|
|
490
|
+
.map((u) => s(u, 300)),
|
|
491
|
+
url: advisoryUrl(v.id),
|
|
492
|
+
affectsYourProjects,
|
|
493
|
+
};
|
|
494
|
+
}
|