selfguard 0.0.1 → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 selfguard contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,155 @@
1
1
  # selfguard
2
2
 
3
- This version only reserves the package name. It contains no code.
3
+ A local-first guard for the dependencies of **every** JavaScript repo you work on.
4
4
 
5
- The first real release (published from GitHub Actions with provenance) will be available soon:
6
- https://github.com/josseed/selfguard
5
+ - **Hands-off**: `cd` into a project and selfguard registers it and scans it in the background.
6
+ Run `npm install` and it notices that the lockfile changed and re-scans.
7
+ - **Reverse alerts**: `selfguard sync` (or the scheduled job) re-checks all your projects against
8
+ new advisories. You get a macOS notification when a repo you haven't opened in months becomes vulnerable.
9
+ - **Malicious packages first**: OSV includes the OpenSSF malicious-packages feed (`MAL-*`), so
10
+ compromised releases (Shai-Hulud style) are flagged above any CVE.
11
+ - **Incident response**: `selfguard where <pkg>` answers "do I use this anywhere?" in one command.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install -g selfguard
17
+ selfguard setup
18
+ ```
19
+
20
+ `setup` is a small terminal UI: pick the folders where you keep your repos (it suggests
21
+ `~/repository`, `~/code`, `~/dev`… and shows how many projects each has), then it scans them and
22
+ offers to install the shell hook and the background sync.
23
+
24
+ ```
25
+ Where do you keep your code?
26
+ selfguard only looks inside the folders you pick, nothing else on your disk.
27
+
28
+ [x] ~/repository 205 projects depth 3
29
+ › [x] ~/code 12 projects depth 2
30
+ + add another folder…
31
+
32
+ ↑/↓ move · space select · ←/→ depth · a add folder · enter continue · q quit
33
+ ```
34
+
35
+ ## How projects are found
36
+
37
+ selfguard **never crawls your whole disk**. A project gets tracked in one of three ways:
38
+
39
+ 1. **Your folders** (`setup` / `roots add`): a recursive search of every subfolder (depth-limited,
40
+ default 5) that only skips dependency/generated folders: `node_modules`, `bower_components`,
41
+ `vendor`, `Pods`, `venv`, `target`, `__pycache__`, hidden folders (`.git`, `.next`, `.cache`…),
42
+ and symlinks. It also looks inside projects, so monorepos with several lockfiles are covered.
43
+ `sync` re-runs it, so a fresh `git clone` gets watched even if you never open it.
44
+ 2. **The shell hook**: `cd` anywhere and the enclosing project is registered.
45
+ 3. **By hand**: `selfguard scan <dir>`.
46
+
47
+ Worktrees and separate clones of the same repo are **grouped** (by remote URL, or by the main
48
+ repo a worktree points to), and each subfolder project of a monorepo stays its own group:
49
+
50
+ ```
51
+ CRITICAL acme/datahub 31 vulnerable pkg(s) on main · 82 on feat/calculadora 3 checkouts
52
+ ├ main 31 vulnerable ~/code/datahub
53
+ ├ fix/query-perf worktree 30 vulnerable ~/code/datahub-query-perf
54
+ └ feat/calculadora worktree 82 vulnerable ~/code/datahub-feat-calculadora
55
+ ```
56
+
57
+ Notifications about the same advisory in several checkouts of one repo arrive once. `sync` stops
58
+ tracking folders that no longer exist (e.g. removed worktrees). `status --flat` lists every checkout.
59
+
60
+ Test fixtures, examples or archived repos you don't care about: `selfguard forget <dir>` stops
61
+ tracking them and excludes them from discovery (undo with `selfguard unexclude <dir>`).
62
+
63
+ ## Use it with AI (MCP)
64
+
65
+ selfguard ships an MCP server, so assistants like Claude Code or Cursor can triage and manage it:
66
+
67
+ ```sh
68
+ claude mcp add selfguard -- selfguard mcp
69
+ ```
70
+
71
+ Then ask things like *"which of my projects have critical vulnerabilities and how do I fix them?"*,
72
+ *"is `@ctrl/tinycolor` used anywhere?"* or *"stop tracking the test fixtures in ~/code/shop"*.
73
+
74
+ | Tool | Purpose |
75
+ |---|---|
76
+ | `selfguard_status` | All projects with risk summary |
77
+ | `selfguard_project_report` | Vulnerable packages of a project, with `upgradeTo` |
78
+ | `selfguard_scan` / `selfguard_sync` | Re-check one project / everything |
79
+ | `selfguard_where` | Which projects use a package |
80
+ | `selfguard_vulnerability` | Advisory details + which of your projects it affects |
81
+ | `selfguard_roots_*`, `selfguard_discover` | Manage watched folders |
82
+ | `selfguard_forget` / `selfguard_unexclude` | Stop tracking / re-include |
83
+
84
+ The MCP tools never run package managers or project code. Fixes are proposed for the user to apply.
85
+ Advisory text is marked as third-party data so the model treats it as data, not instructions.
86
+
87
+ Without MCP, every command accepts `--json` (one JSON document on stdout, errors as
88
+ `{"error":{"code","message"}}`, exit codes 0/1/2), and `selfguard guide` prints a guide written
89
+ for agents.
90
+
91
+ ## Commands
92
+
93
+ Run `selfguard --help` for everything. The main ones:
94
+
95
+ | Command | What it does |
96
+ |---|---|
97
+ | `selfguard setup` | Interactive: choose your code folders, scan, install hook + sync |
98
+ | `selfguard status [--vulnerable] [--flat]` | Tracked repos, riskiest first (worktrees/clones grouped) |
99
+ | `selfguard scan [dir] [-v]` | Scan a project now (exit code 1 if anything is found) |
100
+ | `selfguard report [dir] [-v]` | Last results for a project, no network |
101
+ | `selfguard sync` | Find new repos in your folders, re-check everything, notify about news |
102
+ | `selfguard where <pkg>` | Which projects use a package, and which versions |
103
+ | `selfguard vuln <id>` | Advisory details and which of your projects it affects |
104
+ | `selfguard roots [add <dir> [--depth N] \| remove <dir>]` | Manage your code folders |
105
+ | `selfguard discover [--dry-run]` | Register new projects found in your folders |
106
+ | `selfguard forget [dir]` / `unexclude <dir>` | Stop tracking + exclude / re-include |
107
+ | `selfguard schedule on\|off\|status` | Periodic sync via launchd (macOS) |
108
+ | `selfguard mcp` / `selfguard guide` | MCP server / guide for AI agents |
109
+
110
+ ## Security model
111
+
112
+ selfguard is a security tool, so it holds itself to a stricter standard:
113
+
114
+ - **Never executes anything from your projects.** Lockfiles are parsed as data. No `npm ls`, no
115
+ lifecycle scripts, no `.npmrc`. Entering a hostile repo is safe.
116
+ - **Zero runtime dependencies.** Only Node built-ins. CI fails if a runtime dependency is added.
117
+ - **Minimal data leaves your machine**: only `name` + `version` pairs go to
118
+ [OSV.dev](https://osv.dev). No paths, repo names, or file contents.
119
+ - **Untrusted text is sanitized** before it reaches your terminal or a notification: no terminal
120
+ escape injection and no bidi spoofing. Notification text goes to AppleScript as arguments, never as code.
121
+ - **Local state is private**: `~/.selfguard` is created `0700` and its files `0600`.
122
+ - **Releases use npm Trusted Publishing with provenance and staged publishing**: no long-lived
123
+ publish token exists, CI can only stage a version, and a maintainer must approve it with 2FA
124
+ before anyone can install it. `npm view selfguard --json` shows the provenance attestation.
125
+
126
+ ## How it works
127
+
128
+ ```
129
+ cd ──► zsh hook (pure shell: walk up for package.json, stat lockfile on each prompt)
130
+ │ only when the project or lockfile changes
131
+ ▼
132
+ selfguard hook-enter ──► cached summary on stderr
133
+ │ lockfile changed / never scanned / >24h
134
+ ▼
135
+ detached background scan ──► parse lockfile ──► OSV querybatch ──► ~/.selfguard
136
+ └─► macOS notification on new findings
137
+ ```
138
+
139
+ Supported lockfiles: `package-lock.json` / `npm-shrinkwrap.json` (v1–v3), `pnpm-lock.yaml`
140
+ (v5, v6, v9), `yarn.lock` (classic v1 and berry v2+) and `bun.lock`. The binary `bun.lockb` is
141
+ not supported (`bun install --save-text-lockfile` converts it). All of them are parsed as data,
142
+ with a dependency-free parser; the test suite checks that real lockfiles from every manager
143
+ produce identical results for the same project.
144
+
145
+ ## Development
146
+
147
+ ```sh
148
+ npm ci --ignore-scripts
149
+ npm test # node:test runs the TypeScript sources directly (Node >= 22.18)
150
+ npm run build # emits dist/
151
+ ```
152
+
153
+ ## License
154
+
155
+ MIT
package/dist/alerts.js ADDED
@@ -0,0 +1,96 @@
1
+ /** Decide what to tell the user (and how) after a scan or sync. Pure: easy to test. */
2
+ import { basename } from "node:path";
3
+ import { criticalDialog, notify } from "./notify.js";
4
+ import { groupByPackage, worstSeverity } from "./report.js";
5
+ const LABEL = {
6
+ malicious: "MALICIOUS",
7
+ critical: "CRITICAL",
8
+ high: "HIGH",
9
+ moderate: "MODERATE",
10
+ low: "LOW",
11
+ unknown: "UNKNOWN",
12
+ };
13
+ const name = (path) => basename(path) || path;
14
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
15
+ function listFixes(findings, max = 3) {
16
+ const groups = groupByPackage(findings);
17
+ const parts = groups.slice(0, max).map((g) => (g.upgradeTo ? `${g.name} → ${g.upgradeTo}` : `${g.name} (no fix yet)`));
18
+ if (groups.length > max)
19
+ parts.push(`+${groups.length - max} more`);
20
+ return parts.join(", ");
21
+ }
22
+ /** Merge checkouts of the same repo into one entry, de-duplicating findings. */
23
+ function byRepo(news) {
24
+ const groups = new Map();
25
+ for (const n of news) {
26
+ if (n.newFindings.length === 0)
27
+ continue;
28
+ const key = n.repo?.key ?? `path:${n.path}`;
29
+ let g = groups.get(key);
30
+ if (!g) {
31
+ g = { path: n.path, newFindings: [], label: n.repo?.label ?? name(n.path), checkouts: 0, seen: new Set() };
32
+ groups.set(key, g);
33
+ }
34
+ g.checkouts++;
35
+ for (const f of n.newFindings) {
36
+ const k = `${f.id}|${f.name}@${f.version}`;
37
+ if (g.seen.has(k))
38
+ continue;
39
+ g.seen.add(k);
40
+ g.newFindings.push(f);
41
+ }
42
+ }
43
+ return [...groups.values()].map(({ seen: _s, ...g }) => g);
44
+ }
45
+ export function buildAlert(news) {
46
+ const affected = byRepo(news);
47
+ if (affected.length === 0)
48
+ return { kind: "none" };
49
+ const all = affected.flatMap((n) => n.newFindings);
50
+ const malicious = affected.flatMap((n) => n.newFindings.filter((f) => f.severity === "malicious").map((f) => ({ f, path: n.path })));
51
+ if (malicious.length > 0) {
52
+ const { f, path } = malicious[0];
53
+ const group = affected.find((n) => n.path === path);
54
+ const where = affected.length === 1 ? group.label : `${affected.length} projects`;
55
+ const others = malicious.length > 1 ? ` (+${malicious.length - 1} more malicious)` : "";
56
+ return {
57
+ kind: "critical",
58
+ title: `⛔ Malicious package in ${where}`,
59
+ subtitle: `${f.name}@${f.version} · ${f.id}`,
60
+ body: "Remove it now and rotate credentials on this machine.",
61
+ dialogTitle: `selfguard: malicious package in ${where}`,
62
+ dialogMessage: `${f.name}@${f.version} is a known malicious package${others}.\n\n` +
63
+ `Project: ${path}${group.checkouts > 1 ? ` (and ${group.checkouts - 1} other checkout(s) of ${group.label})` : ""}\n\n` +
64
+ "1. Remove it (or pin a safe version) and reinstall.\n" +
65
+ "2. Rotate credentials this machine or CI could reach: npm tokens, cloud keys, SSH keys, .env secrets.\n" +
66
+ "3. Check the advisory for what the payload does.",
67
+ url: `https://osv.dev/vulnerability/${encodeURIComponent(f.id)}`,
68
+ };
69
+ }
70
+ const worst = LABEL[worstSeverity(all) ?? "unknown"];
71
+ if (affected.length === 1) {
72
+ const n = affected[0];
73
+ const pkgs = groupByPackage(n.newFindings).length;
74
+ return {
75
+ kind: "banner",
76
+ title: `selfguard: ${n.label}${n.checkouts > 1 ? ` (${n.checkouts} checkouts)` : ""}`,
77
+ subtitle: `${plural(pkgs, "vulnerable package")} · worst: ${worst}`,
78
+ body: listFixes(n.newFindings),
79
+ };
80
+ }
81
+ const pkgs = affected.reduce((sum, n) => sum + groupByPackage(n.newFindings).length, 0);
82
+ const names = affected.map((n) => n.label);
83
+ return {
84
+ kind: "banner",
85
+ title: `selfguard: ${affected.length} projects affected`,
86
+ subtitle: `${plural(pkgs, "vulnerable package")} · worst: ${worst}`,
87
+ body: `${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}. Run: selfguard status`,
88
+ };
89
+ }
90
+ export async function deliver(alert) {
91
+ if (alert.kind === "none")
92
+ return;
93
+ await notify(alert.title, alert.body, alert.subtitle);
94
+ if (alert.kind === "critical")
95
+ await criticalDialog(alert.dialogTitle, alert.dialogMessage, alert.url);
96
+ }
package/dist/api.js ADDED
@@ -0,0 +1,327 @@
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 { clampDepth, DEFAULT_DEPTH, isExcluded, loadConfig, normalizeDir, saveConfig, } from "./config.js";
9
+ import { discoverAll } from "./discover.js";
10
+ import { tryLock } from "./fsutil.js";
11
+ import { findProject } from "./lockfile/detect.js";
12
+ import { evaluate, severityOf, severityRank } from "./match.js";
13
+ import { getVulns } from "./osv.js";
14
+ import { countBySeverity, groupByPackage, worstSeverity } from "./report.js";
15
+ import { repoIdentity } from "./repoid.js";
16
+ import { clean } from "./sanitize.js";
17
+ import { scanProjects } from "./scan.js";
18
+ import { forgetRepo, listRepos, loadRepo, saveRepo } from "./store.js";
19
+ export class ApiError extends Error {
20
+ code;
21
+ constructor(code, message) {
22
+ super(message);
23
+ this.code = code;
24
+ }
25
+ }
26
+ const advisoryUrl = (id) => `https://osv.dev/vulnerability/${encodeURIComponent(id)}`;
27
+ // ---------- helpers ----------
28
+ function summarize(rec) {
29
+ return {
30
+ path: rec.path,
31
+ exists: existsSync(rec.path),
32
+ lockfile: rec.lockfile,
33
+ lockType: rec.lockType,
34
+ packages: rec.deps.length,
35
+ skippedEntries: rec.skipped,
36
+ scannedAt: rec.scannedAt,
37
+ error: rec.error ?? null,
38
+ worstSeverity: worstSeverity(rec.findings),
39
+ vulnerablePackages: groupByPackage(rec.findings).length,
40
+ advisories: rec.findings.length,
41
+ bySeverity: countBySeverity(rec.findings),
42
+ repo: existsSync(rec.path) ? repoIdentity(rec.path) : null,
43
+ };
44
+ }
45
+ export function toReport(rec) {
46
+ return {
47
+ ...summarize(rec),
48
+ vulnerable: groupByPackage(rec.findings).map((g) => ({
49
+ name: g.name,
50
+ version: g.version,
51
+ direct: g.direct,
52
+ dev: g.dev,
53
+ worstSeverity: g.worst,
54
+ upgradeTo: g.worst === "malicious" ? null : (g.upgradeTo ?? null),
55
+ advisoriesWithoutFix: g.unfixed,
56
+ advisories: g.findings.map((f) => ({
57
+ id: f.id,
58
+ aliases: f.aliases,
59
+ severity: f.severity,
60
+ summary: f.summary,
61
+ fixedIn: f.fixedIn ?? null,
62
+ firstSeen: f.firstSeen,
63
+ url: advisoryUrl(f.id),
64
+ })),
65
+ })),
66
+ };
67
+ }
68
+ function resolveProject(path) {
69
+ const abs = resolve(path);
70
+ if (!existsSync(abs))
71
+ throw new ApiError("not_found", `no such directory: ${abs}`);
72
+ const project = findProject(abs);
73
+ if (!project)
74
+ throw new ApiError("no_project", `no package.json found in ${abs} or its parents`);
75
+ return project;
76
+ }
77
+ // ---------- operations ----------
78
+ export function getStatus(opts = {}) {
79
+ const rank = (w) => (w ? severityRank(w) : 99);
80
+ const records = listRepos();
81
+ const summaries = records.map(summarize);
82
+ const groups = new Map();
83
+ summaries.forEach((p, i) => {
84
+ const rec = records[i];
85
+ const key = p.repo?.key ?? `path:${p.path}`;
86
+ let g = groups.get(key);
87
+ if (!g) {
88
+ const label = p.repo?.label ?? (p.path.split("/").pop() || p.path);
89
+ g = { group: { key, label, worstSeverity: null, vulnerablePackages: 0, checkouts: [] }, pkgs: new Set(), findings: [] };
90
+ groups.set(key, g);
91
+ }
92
+ g.group.checkouts.push({
93
+ path: p.path,
94
+ branch: p.repo?.branch ?? null,
95
+ worktree: p.repo?.worktree ?? false,
96
+ exists: p.exists,
97
+ worstSeverity: p.worstSeverity,
98
+ vulnerablePackages: p.vulnerablePackages,
99
+ advisories: p.advisories,
100
+ error: p.error,
101
+ });
102
+ for (const f of rec.findings)
103
+ g.pkgs.add(`${f.name}@${f.version}`);
104
+ g.findings.push(...rec.findings);
105
+ });
106
+ const repos = [...groups.values()]
107
+ .map(({ group, pkgs, findings }) => {
108
+ group.worstSeverity = worstSeverity(findings);
109
+ group.vulnerablePackages = pkgs.size;
110
+ // main checkout first, then riskiest worktrees
111
+ group.checkouts.sort((a, b) => Number(a.worktree) - Number(b.worktree) || rank(a.worstSeverity) - rank(b.worstSeverity) || a.path.localeCompare(b.path));
112
+ return group;
113
+ })
114
+ .filter((g) => !opts.onlyVulnerable || g.vulnerablePackages > 0)
115
+ .sort((a, b) => rank(a.worstSeverity) - rank(b.worstSeverity) || b.vulnerablePackages - a.vulnerablePackages || a.label.localeCompare(b.label));
116
+ const projects = summaries
117
+ .filter((p) => !opts.onlyVulnerable || p.advisories > 0)
118
+ .sort((a, b) => rank(a.worstSeverity) - rank(b.worstSeverity) || b.advisories - a.advisories || a.path.localeCompare(b.path));
119
+ return { repos, projects };
120
+ }
121
+ /** Last stored report for a project (no network). */
122
+ export function getReport(path) {
123
+ const project = resolveProject(path);
124
+ const rec = loadRepo(project.root);
125
+ if (!rec)
126
+ throw new ApiError("not_tracked", `${project.root} is not tracked yet; scan it first`);
127
+ return toReport(rec);
128
+ }
129
+ export async function scanPath(path) {
130
+ const project = resolveProject(path);
131
+ const release = tryLock(`scan:${project.root}`);
132
+ if (!release)
133
+ throw new ApiError("busy", "another scan of this project is running, try again in a moment");
134
+ try {
135
+ const [o] = await scanProjects([project]);
136
+ saveRepo(o.record);
137
+ return { project: toReport(o.record), firstScan: o.firstScan, newAdvisories: o.newFindings.length };
138
+ }
139
+ finally {
140
+ release();
141
+ }
142
+ }
143
+ export async function sync() {
144
+ const release = tryLock("sync");
145
+ if (!release)
146
+ throw new ApiError("busy", "a sync is already running");
147
+ try {
148
+ const cfg = loadConfig();
149
+ const projects = [];
150
+ const seen = new Set();
151
+ const pruned = [];
152
+ for (const r of listRepos()) {
153
+ if (!existsSync(r.path)) {
154
+ forgetRepo(r.path); // e.g. a worktree you removed: nothing left to protect
155
+ pruned.push(r.path);
156
+ continue;
157
+ }
158
+ if (isExcluded(r.path, cfg.exclude))
159
+ continue;
160
+ const p = findProject(r.path);
161
+ if (p && p.root === r.path) {
162
+ projects.push(p);
163
+ seen.add(p.root);
164
+ }
165
+ }
166
+ // New clones inside your folders get tracked without you ever cd-ing into them.
167
+ const discovered = (await discoverAll(cfg.roots, { exclude: cfg.exclude })).filter((p) => !seen.has(p.root));
168
+ projects.push(...discovered);
169
+ const outcomes = await scanProjects(projects);
170
+ for (const o of outcomes)
171
+ saveRepo(o.record);
172
+ return {
173
+ projects: projects.length,
174
+ discovered: discovered.map((p) => p.root),
175
+ pruned,
176
+ news: outcomes
177
+ .filter((o) => o.newFindings.length > 0)
178
+ .map((o) => ({
179
+ path: o.record.path,
180
+ packages: groupByPackage(o.newFindings).map((g) => ({
181
+ name: g.name,
182
+ version: g.version,
183
+ worstSeverity: g.worst,
184
+ advisories: g.findings.map((f) => f.id),
185
+ })),
186
+ })),
187
+ errors: outcomes.filter((o) => o.record.error).map((o) => ({ path: o.record.path, error: o.record.error })),
188
+ outcomes,
189
+ };
190
+ }
191
+ finally {
192
+ release();
193
+ }
194
+ }
195
+ export async function discoverNew(opts = {}) {
196
+ const cfg = loadConfig();
197
+ if (cfg.roots.length === 0)
198
+ throw new ApiError("no_roots", "no folders configured; add one with roots add <dir>");
199
+ const known = new Set(listRepos().map((r) => r.path));
200
+ const fresh = (await discoverAll(cfg.roots, { exclude: cfg.exclude })).filter((p) => !known.has(p.root));
201
+ const found = fresh.map((p) => ({ path: p.root, lockType: p.lockType }));
202
+ if (opts.dryRun || fresh.length === 0)
203
+ return { dryRun: Boolean(opts.dryRun), found, registered: 0, withVulnerabilities: 0 };
204
+ const outcomes = await scanProjects(fresh);
205
+ for (const o of outcomes)
206
+ saveRepo(o.record);
207
+ return {
208
+ dryRun: false,
209
+ found,
210
+ registered: outcomes.length,
211
+ withVulnerabilities: outcomes.filter((o) => o.record.findings.length > 0).length,
212
+ };
213
+ }
214
+ export function whereUsed(name) {
215
+ const usedIn = [];
216
+ for (const r of listRepos()) {
217
+ const versions = r.deps
218
+ .filter((d) => d.name === name)
219
+ .map((d) => ({
220
+ version: d.version,
221
+ direct: d.direct,
222
+ dev: d.dev,
223
+ vulnerable: r.findings.some((f) => f.name === name && f.version === d.version),
224
+ }));
225
+ if (versions.length)
226
+ usedIn.push({ path: r.path, versions });
227
+ }
228
+ return { package: name, usedIn };
229
+ }
230
+ export function listRoots() {
231
+ const { roots, exclude } = loadConfig();
232
+ return { roots, exclude };
233
+ }
234
+ export function addRoot(path, depth) {
235
+ const dir = normalizeDir(path);
236
+ if (!dir)
237
+ throw new ApiError("not_found", `not a directory: ${path}`);
238
+ const cfg = loadConfig();
239
+ const d = depth === undefined ? DEFAULT_DEPTH : clampDepth(depth);
240
+ cfg.roots = [...cfg.roots.filter((r) => r.path !== dir), { path: dir, depth: d }];
241
+ saveConfig(cfg);
242
+ return { roots: cfg.roots, exclude: cfg.exclude };
243
+ }
244
+ export function removeRoot(path) {
245
+ const cfg = loadConfig();
246
+ const abs = normalizeDir(path) ?? resolve(path);
247
+ const before = cfg.roots.length;
248
+ cfg.roots = cfg.roots.filter((r) => r.path !== abs);
249
+ saveConfig(cfg);
250
+ return { roots: cfg.roots, exclude: cfg.exclude, removed: cfg.roots.length < before };
251
+ }
252
+ /**
253
+ * Stop tracking a path. With `exclude` (default), it's also excluded from discovery
254
+ * so it never comes back (use for fixtures, examples, archived repos).
255
+ */
256
+ export function forget(path, opts = {}) {
257
+ const abs = normalizeDir(path) ?? resolve(path);
258
+ const root = loadRepo(abs) ? abs : (findProject(abs)?.root ?? abs);
259
+ const wasTracked = forgetRepo(root);
260
+ const alsoForgotten = [];
261
+ const exclude = opts.exclude ?? true;
262
+ if (exclude) {
263
+ const cfg = loadConfig();
264
+ if (!cfg.exclude.includes(root))
265
+ cfg.exclude.push(root);
266
+ saveConfig(cfg);
267
+ // Excluding a folder also drops tracked projects below it.
268
+ for (const r of listRepos()) {
269
+ if (r.path !== root && isExcluded(r.path, [root])) {
270
+ forgetRepo(r.path);
271
+ alsoForgotten.push(r.path);
272
+ }
273
+ }
274
+ }
275
+ return { path: root, wasTracked, excluded: exclude, alsoForgotten };
276
+ }
277
+ export function unexclude(path) {
278
+ const cfg = loadConfig();
279
+ const abs = normalizeDir(path) ?? resolve(path);
280
+ const before = cfg.exclude.length;
281
+ cfg.exclude = cfg.exclude.filter((e) => e !== abs);
282
+ saveConfig(cfg);
283
+ return { roots: cfg.roots, exclude: cfg.exclude, removed: cfg.exclude.length < before };
284
+ }
285
+ export async function getVulnerability(id) {
286
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))
287
+ throw new ApiError("invalid", "invalid advisory id");
288
+ const vulns = await getVulns([{ id, modified: "" }]);
289
+ const v = vulns.get(id);
290
+ if (!v)
291
+ throw new ApiError("not_found", `advisory ${id} not found`);
292
+ const s = (x, n) => clean(x ?? "", n);
293
+ const affectsYourProjects = [];
294
+ for (const r of listRepos()) {
295
+ for (const d of r.deps) {
296
+ const ev = evaluate(v, d.name, d.version);
297
+ if (ev.affected)
298
+ affectsYourProjects.push({ path: r.path, package: d.name, version: d.version, fixedIn: ev.fixedIn ?? null });
299
+ }
300
+ }
301
+ const raw = v;
302
+ return {
303
+ id: v.id,
304
+ aliases: (v.aliases ?? []).map((a) => s(a, 64)),
305
+ severity: severityOf(v),
306
+ summary: s(v.summary, 300),
307
+ details: clean(v.details ?? "", 4000),
308
+ published: typeof raw.published === "string" ? raw.published : null,
309
+ modified: v.modified ?? null,
310
+ withdrawn: v.withdrawn ?? null,
311
+ affected: (v.affected ?? [])
312
+ .filter((a) => a.package?.ecosystem === "npm")
313
+ .slice(0, 20)
314
+ .map((a) => ({
315
+ package: s(a.package?.name, 214),
316
+ ranges: (a.ranges ?? []).flatMap((r) => (r.events ?? []).map((e) => ({ ...e }))).slice(0, 50),
317
+ versions: (a.versions ?? []).slice(0, 50).map((x) => s(x, 64)),
318
+ })),
319
+ references: (Array.isArray(raw.references) ? raw.references : [])
320
+ .map((r) => (r && typeof r === "object" ? r.url : undefined))
321
+ .filter((u) => typeof u === "string" && /^https:\/\//.test(u))
322
+ .slice(0, 15)
323
+ .map((u) => s(u, 300)),
324
+ url: advisoryUrl(v.id),
325
+ affectsYourProjects,
326
+ };
327
+ }