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 +21 -0
- package/README.md +152 -3
- package/dist/alerts.js +96 -0
- package/dist/api.js +327 -0
- package/dist/cli.js +462 -0
- package/dist/config.js +70 -0
- package/dist/discover.js +73 -0
- package/dist/fsutil.js +66 -0
- package/dist/guide.js +53 -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 +212 -0
- package/dist/notify.js +43 -0
- package/dist/osv.js +142 -0
- package/dist/paths.js +13 -0
- package/dist/repoid.js +145 -0
- package/dist/report.js +126 -0
- package/dist/sanitize.js +12 -0
- package/dist/scan.js +168 -0
- package/dist/schedule.js +82 -0
- package/dist/semver.js +65 -0
- package/dist/setup.js +233 -0
- package/dist/store.js +37 -0
- package/dist/tui.js +132 -0
- package/dist/version.js +2 -0
- package/package.json +45 -4
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
|
-
|
|
3
|
+
A local-first guard for the dependencies of **every** JavaScript repo you work on.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
}
|