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/README.md
CHANGED
|
@@ -1,155 +1,136 @@
|
|
|
1
1
|
# selfguard
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/selfguard)
|
|
4
|
+
[](https://github.com/josseed/selfguard/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/selfguard#provenance)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**A local-first guard for the dependencies of every JavaScript repo on your machine.**
|
|
9
|
+
|
|
10
|
+
Most dependency scanners only check a project when you remember to run them, or only for repos
|
|
11
|
+
hosted on a platform. selfguard tracks every project on your machine and checks their lockfiles
|
|
12
|
+
against [OSV.dev](https://osv.dev). It tells you when one of them is affected by a new
|
|
13
|
+
vulnerability or a malicious package, including repos you haven't opened in months.
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
$ selfguard status
|
|
17
|
+
CRITICAL acme/storefront 31 vulnerable pkg(s) on main · 82 on feat/checkout 3 checkouts
|
|
18
|
+
├ main 31 vulnerable ~/code/storefront
|
|
19
|
+
├ fix/query-perf worktree 30 vulnerable ~/code/storefront-query-perf
|
|
20
|
+
└ feat/checkout worktree 82 vulnerable ~/code/storefront-feat-checkout
|
|
21
|
+
HIGH acme/payments-api 3 vulnerable pkg(s) ~/code/payments-api
|
|
22
|
+
✓ acme/docs-site clean ~/code/docs-site
|
|
23
|
+
```
|
|
12
24
|
|
|
13
|
-
##
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **It tells you on its own.** A background watcher re-checks a repo seconds after its lockfile
|
|
28
|
+
changes (`git pull`, installs, merges), a scheduled sync catches newly published advisories,
|
|
29
|
+
and a daily reminder lists what is still open and serious. When you fix something, you get a
|
|
30
|
+
"✓ fixed" notification too.
|
|
31
|
+
- **A dashboard, not just a terminal.** `selfguard ui` opens a local page with every repo, what
|
|
32
|
+
needs attention, how to fix it, and a feed of what changed.
|
|
33
|
+
- **Signal over noise.** Only checkouts with recent git activity alert you. Old clones and
|
|
34
|
+
abandoned worktrees stay listed but quiet (except for malicious packages), and a new worktree
|
|
35
|
+
of a known repo isn't reported as news.
|
|
36
|
+
- **Hands-off.** `cd` into a project and it is registered and scanned in the background. New
|
|
37
|
+
clones in your code folders are picked up automatically.
|
|
38
|
+
- **Malicious packages first.** OSV includes the OpenSSF malicious-packages feed (`MAL-*`).
|
|
39
|
+
Compromised releases, like the Shai-Hulud npm worm, outrank any CVE and open a dialog that
|
|
40
|
+
stays on screen until you dismiss it.
|
|
41
|
+
- **Actionable output.** One upgrade target per vulnerable package ("upgrade lodash to 4.18.0"),
|
|
42
|
+
not a wall of advisories.
|
|
43
|
+
- **Every package manager.** npm, pnpm, yarn (classic and berry) and bun lockfiles.
|
|
44
|
+
- **Repo-aware.** Git worktrees and clones of the same repo are grouped, each with its branch.
|
|
45
|
+
- **Incident response.** `selfguard where <package>` answers "do I use this anywhere?" in one
|
|
46
|
+
command.
|
|
47
|
+
- **AI-ready.** A built-in MCP server, `--json` output on every command, and a guide for agents.
|
|
48
|
+
- **Safe by design.** It never executes code from your projects, has zero runtime dependencies,
|
|
49
|
+
and only sends package names and versions over the network.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
Requires **Node.js 20.10+**. Works on **macOS** and **Linux**. Native notifications and
|
|
54
|
+
scheduling are macOS-only for now.
|
|
14
55
|
|
|
15
56
|
```sh
|
|
16
57
|
npm install -g selfguard
|
|
17
58
|
selfguard setup
|
|
18
59
|
```
|
|
19
60
|
|
|
20
|
-
`setup` is a small terminal UI
|
|
21
|
-
|
|
22
|
-
|
|
61
|
+
`setup` is a small terminal UI. Pick the folders where you keep your code, and selfguard scans
|
|
62
|
+
them. It then offers to install the shell hook and background watching with notifications.
|
|
63
|
+
Then open the dashboard:
|
|
23
64
|
|
|
65
|
+
```sh
|
|
66
|
+
selfguard ui
|
|
24
67
|
```
|
|
68
|
+
|
|
69
|
+
```text
|
|
25
70
|
Where do you keep your code?
|
|
26
71
|
selfguard only looks inside the folders you pick, nothing else on your disk.
|
|
27
72
|
|
|
28
|
-
[x] ~/
|
|
29
|
-
› [
|
|
73
|
+
[x] ~/code 42 projects depth 5
|
|
74
|
+
› [ ] ~/work 17 projects depth 5
|
|
30
75
|
+ add another folder…
|
|
31
76
|
|
|
32
77
|
↑/↓ move · space select · ←/→ depth · a add folder · enter continue · q quit
|
|
33
78
|
```
|
|
34
79
|
|
|
35
|
-
|
|
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>`).
|
|
80
|
+
Or scan a single project without setting anything up:
|
|
62
81
|
|
|
63
|
-
|
|
82
|
+
```text
|
|
83
|
+
$ selfguard scan
|
|
84
|
+
~/code/payments-api 412 packages
|
|
85
|
+
3 vulnerable package(s), 9 advisories: 1 critical, 4 high, 4 moderate
|
|
64
86
|
|
|
65
|
-
|
|
87
|
+
CRITICAL minimist@1.2.5 (transitive)
|
|
88
|
+
→ upgrade to 1.2.6 or later
|
|
89
|
+
• GHSA-xvch-5gv4-984h critical Prototype Pollution in minimist
|
|
66
90
|
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
HIGH axios@0.21.0 (direct)
|
|
92
|
+
→ upgrade to 0.33.0 or later
|
|
93
|
+
• GHSA-cph5-m8f7-6c5x high axios Inefficient Regular Expression Complexity vulnerability
|
|
94
|
+
…
|
|
69
95
|
```
|
|
70
96
|
|
|
71
|
-
|
|
72
|
-
*"is `@ctrl/tinycolor` used anywhere?"* or *"stop tracking the test fixtures in ~/code/shop"*.
|
|
97
|
+
## Documentation
|
|
73
98
|
|
|
74
|
-
|
|
|
99
|
+
| | |
|
|
75
100
|
|---|---|
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
|
|
|
79
|
-
|
|
|
80
|
-
|
|
|
81
|
-
|
|
|
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.
|
|
101
|
+
| [Getting started](docs/getting-started.md) | Install, setup, dashboard, alerts, uninstall |
|
|
102
|
+
| [CLI reference](docs/cli.md) | Every command and flag, exit codes, environment variables |
|
|
103
|
+
| [How it works](docs/how-it-works.md) | Discovery, lockfile parsing, repo grouping, OSV, caching, alerts |
|
|
104
|
+
| [AI and automation](docs/ai-and-automation.md) | MCP server setup, tools, JSON output, scripting and CI |
|
|
105
|
+
| [Security model](docs/security-model.md) | What selfguard trusts, what it sends, how it is released |
|
|
106
|
+
| [Troubleshooting](docs/troubleshooting.md) | Notifications, hook, scheduling, unsupported lockfiles |
|
|
90
107
|
|
|
91
|
-
##
|
|
108
|
+
## How it compares
|
|
92
109
|
|
|
93
|
-
|
|
110
|
+
| | selfguard | `npm audit` | osv-scanner | Dependabot |
|
|
111
|
+
|---|---|---|---|---|
|
|
112
|
+
| Watches every project on your machine | ✅ | ❌ one project, on demand | ❌ on demand | ❌ hosted repos only |
|
|
113
|
+
| Alerts when a new advisory hits an old project | ✅ | ❌ | ❌ | ✅ |
|
|
114
|
+
| Local-only (no repo hosting required) | ✅ | ✅ | ✅ | ❌ |
|
|
115
|
+
| npm, pnpm, yarn, bun | ✅ | npm only | ✅ | ✅ |
|
|
116
|
+
| Malicious package feed | ✅ via OSV | ❌ | ✅ via OSV | partial |
|
|
117
|
+
| Opens PRs with fixes | ❌ | ❌ (`npm audit fix` locally) | guided remediation | ✅ |
|
|
118
|
+
| Ecosystems | JavaScript | JavaScript | many | many |
|
|
94
119
|
|
|
95
|
-
|
|
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
|
|
120
|
+
selfguard complements these tools rather than replacing them. Keep Dependabot or Renovate for
|
|
121
|
+
automated fix PRs, and use selfguard to know what is vulnerable on your machine right now.
|
|
127
122
|
|
|
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
|
-
```
|
|
123
|
+
## Privacy
|
|
138
124
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
with a dependency-free parser; the test suite checks that real lockfiles from every manager
|
|
143
|
-
produce identical results for the same project.
|
|
125
|
+
selfguard sends only `name` and `version` pairs of your locked dependencies to the OSV.dev API.
|
|
126
|
+
No file paths, repo names or source code ever leave your machine. All state lives in
|
|
127
|
+
`~/.selfguard`, readable only by your user. See the [security model](docs/security-model.md).
|
|
144
128
|
|
|
145
|
-
##
|
|
129
|
+
## Contributing
|
|
146
130
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
npm test # node:test runs the TypeScript sources directly (Node >= 22.18)
|
|
150
|
-
npm run build # emits dist/
|
|
151
|
-
```
|
|
131
|
+
Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
132
|
+
Please report security issues privately as described in [SECURITY.md](SECURITY.md).
|
|
152
133
|
|
|
153
134
|
## License
|
|
154
135
|
|
|
155
|
-
MIT
|
|
136
|
+
[MIT](LICENSE)
|
package/dist/activity.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
export function activityOf(repo, activeDays, now = Date.now()) {
|
|
3
|
+
// Not a git checkout: we can't tell, so it counts (you added it on purpose).
|
|
4
|
+
if (!repo)
|
|
5
|
+
return { active: true, lastActivity: null };
|
|
6
|
+
if (repo.orphaned)
|
|
7
|
+
return { active: false, lastActivity: null };
|
|
8
|
+
if (!repo.lastActivity)
|
|
9
|
+
return { active: true, lastActivity: null };
|
|
10
|
+
const age = now - Date.parse(repo.lastActivity);
|
|
11
|
+
return { active: age <= activeDays * DAY_MS, lastActivity: repo.lastActivity };
|
|
12
|
+
}
|
package/dist/alerts.js
CHANGED
|
@@ -43,7 +43,8 @@ function byRepo(news) {
|
|
|
43
43
|
return [...groups.values()].map(({ seen: _s, ...g }) => g);
|
|
44
44
|
}
|
|
45
45
|
export function buildAlert(news) {
|
|
46
|
-
const
|
|
46
|
+
const relevant = news.map((n) => n.active === false ? { ...n, newFindings: n.newFindings.filter((f) => f.severity === "malicious") } : n);
|
|
47
|
+
const affected = byRepo(relevant);
|
|
47
48
|
if (affected.length === 0)
|
|
48
49
|
return { kind: "none" };
|
|
49
50
|
const all = affected.flatMap((n) => n.newFindings);
|
|
@@ -87,6 +88,98 @@ export function buildAlert(news) {
|
|
|
87
88
|
body: `${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}. Run: selfguard status`,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
91
|
+
const pkgKey = (f) => `${f.name}@${f.version}`;
|
|
92
|
+
/** "datahub ✓ 17 vulnerable packages fixed": only for active checkouts, grouped by repo. */
|
|
93
|
+
export function buildResolvedAlert(news) {
|
|
94
|
+
const groups = new Map();
|
|
95
|
+
for (const n of news) {
|
|
96
|
+
if (n.active === false || !n.resolvedFindings?.length)
|
|
97
|
+
continue;
|
|
98
|
+
const still = new Set((n.findings ?? []).map(pkgKey));
|
|
99
|
+
const fixed = n.resolvedFindings.map(pkgKey).filter((k) => !still.has(k));
|
|
100
|
+
if (fixed.length === 0)
|
|
101
|
+
continue;
|
|
102
|
+
const key = n.repo?.key ?? `path:${n.path}`;
|
|
103
|
+
let g = groups.get(key);
|
|
104
|
+
if (!g) {
|
|
105
|
+
g = { label: n.repo?.label ?? name(n.path), fixed: new Set(), left: new Map() };
|
|
106
|
+
groups.set(key, g);
|
|
107
|
+
}
|
|
108
|
+
for (const k of fixed)
|
|
109
|
+
g.fixed.add(k);
|
|
110
|
+
for (const f of n.findings ?? [])
|
|
111
|
+
g.left.set(`${f.id}|${pkgKey(f)}`, f);
|
|
112
|
+
}
|
|
113
|
+
if (groups.size === 0)
|
|
114
|
+
return { kind: "none" };
|
|
115
|
+
const list = [...groups.values()];
|
|
116
|
+
const total = list.reduce((sum, g) => sum + g.fixed.size, 0);
|
|
117
|
+
if (list.length === 1) {
|
|
118
|
+
const g = list[0];
|
|
119
|
+
const left = [...g.left.values()];
|
|
120
|
+
const leftPkgs = groupByPackage(left).length;
|
|
121
|
+
return {
|
|
122
|
+
kind: "banner",
|
|
123
|
+
title: `selfguard: ${g.label} ✓`,
|
|
124
|
+
subtitle: `${plural(g.fixed.size, "vulnerable package")} fixed`,
|
|
125
|
+
body: leftPkgs
|
|
126
|
+
? `${leftPkgs} still vulnerable · worst: ${LABEL[worstSeverity(left) ?? "unknown"]}`
|
|
127
|
+
: "No known vulnerabilities left.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const names = list.map((g) => g.label);
|
|
131
|
+
return {
|
|
132
|
+
kind: "banner",
|
|
133
|
+
title: `selfguard: fixes in ${list.length} repos`,
|
|
134
|
+
subtitle: `${plural(total, "vulnerable package")} fixed`,
|
|
135
|
+
body: `${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* What a reminder nags about: any malicious package; critical vulnerabilities outside dev
|
|
140
|
+
* dependencies; high ones in direct (non-dev) dependencies. The rest stays in the dashboard,
|
|
141
|
+
* so the reminder remains rare enough to be read.
|
|
142
|
+
*/
|
|
143
|
+
export const needsAttention = (f) => f.severity === "malicious" || (!f.dev && (f.severity === "critical" || (f.severity === "high" && f.direct)));
|
|
144
|
+
function severityCounts(findings) {
|
|
145
|
+
const counts = new Map();
|
|
146
|
+
for (const g of groupByPackage(findings))
|
|
147
|
+
counts.set(g.worst, (counts.get(g.worst) ?? 0) + 1);
|
|
148
|
+
return ["malicious", "critical", "high"]
|
|
149
|
+
.filter((s) => counts.get(s))
|
|
150
|
+
.map((s) => `${counts.get(s)} ${s}`)
|
|
151
|
+
.join(", ");
|
|
152
|
+
}
|
|
153
|
+
/** Periodic reminder of unresolved serious findings in the repos you work on. */
|
|
154
|
+
export function buildReminder(repos) {
|
|
155
|
+
const rank = (s) => ["malicious", "critical", "high"].indexOf(s ?? "");
|
|
156
|
+
const serious = repos
|
|
157
|
+
.map((r) => ({ label: r.label, findings: r.findings.filter(needsAttention) }))
|
|
158
|
+
.filter((r) => r.findings.length > 0)
|
|
159
|
+
.sort((a, b) => rank(worstSeverity(a.findings)) - rank(worstSeverity(b.findings)) ||
|
|
160
|
+
groupByPackage(b.findings).length - groupByPackage(a.findings).length);
|
|
161
|
+
if (serious.length === 0)
|
|
162
|
+
return { kind: "none" };
|
|
163
|
+
const all = serious.flatMap((r) => r.findings);
|
|
164
|
+
if (serious.length === 1) {
|
|
165
|
+
const r = serious[0];
|
|
166
|
+
return {
|
|
167
|
+
kind: "banner",
|
|
168
|
+
title: `selfguard: ${r.label} needs attention`,
|
|
169
|
+
subtitle: `${severityCounts(r.findings)} · open: selfguard ui`,
|
|
170
|
+
body: listFixes(r.findings),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const parts = serious.slice(0, 3).map((r) => `${r.label} (${severityCounts(r.findings)})`);
|
|
174
|
+
if (serious.length > 3)
|
|
175
|
+
parts.push(`+${serious.length - 3} more`);
|
|
176
|
+
return {
|
|
177
|
+
kind: "banner",
|
|
178
|
+
title: `selfguard: ${serious.length} repos need attention`,
|
|
179
|
+
subtitle: `worst: ${LABEL[worstSeverity(all) ?? "unknown"]} · open: selfguard ui`,
|
|
180
|
+
body: parts.join(", "),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
90
183
|
export async function deliver(alert) {
|
|
91
184
|
if (alert.kind === "none")
|
|
92
185
|
return;
|
package/dist/announce.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side effects after a scan: record what changed in the activity log and tell the user
|
|
3
|
+
* (new advisories, fixes, periodic reminders). Decisions live in alerts.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { activityOf } from "./activity.js";
|
|
6
|
+
import { buildAlert, buildReminder, buildResolvedAlert, deliver, needsAttention } from "./alerts.js";
|
|
7
|
+
import { loadConfig } from "./config.js";
|
|
8
|
+
import { appendEvents, eventPackages } from "./events.js";
|
|
9
|
+
import { repoIdentity } from "./repoid.js";
|
|
10
|
+
import { loadState, updateState } from "./state.js";
|
|
11
|
+
import { findingKey, listRepos } from "./store.js";
|
|
12
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Findings already known in other checkouts of the same repo (current or recently seen).
|
|
15
|
+
* A new worktree or clone of a repo isn't news: only what no checkout had before is.
|
|
16
|
+
*/
|
|
17
|
+
function knownElsewhere(outcomes) {
|
|
18
|
+
const scanned = new Set(outcomes.map((o) => o.record.path));
|
|
19
|
+
let byRepo = null;
|
|
20
|
+
return (key, path) => {
|
|
21
|
+
if (!byRepo) {
|
|
22
|
+
byRepo = new Map();
|
|
23
|
+
for (const rec of listRepos()) {
|
|
24
|
+
const k = repoIdentity(rec.path)?.key ?? `path:${rec.path}`;
|
|
25
|
+
const keys = new Set([...Object.keys(rec.seen ?? {}), ...rec.findings.map(findingKey)]);
|
|
26
|
+
const list = byRepo.get(k) ?? [];
|
|
27
|
+
list.push({ path: rec.path, keys });
|
|
28
|
+
byRepo.set(k, list);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const out = new Set();
|
|
32
|
+
for (const other of byRepo.get(key) ?? []) {
|
|
33
|
+
// Other checkouts scanned in this same run don't count: they're news too.
|
|
34
|
+
if (other.path === path || scanned.has(other.path))
|
|
35
|
+
continue;
|
|
36
|
+
for (const k of other.keys)
|
|
37
|
+
out.add(k);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function toNews(outcomes, activeDays) {
|
|
43
|
+
const known = knownElsewhere(outcomes);
|
|
44
|
+
return outcomes.map((o) => {
|
|
45
|
+
const repo = repoIdentity(o.record.path);
|
|
46
|
+
const key = repo?.key ?? `path:${o.record.path}`;
|
|
47
|
+
const elsewhere = o.newFindings.length ? known(key, o.record.path) : new Set();
|
|
48
|
+
return {
|
|
49
|
+
path: o.record.path,
|
|
50
|
+
newFindings: o.newFindings.filter((f) => !elsewhere.has(findingKey(f))),
|
|
51
|
+
resolvedFindings: o.resolvedFindings,
|
|
52
|
+
findings: o.record.findings,
|
|
53
|
+
repo,
|
|
54
|
+
active: activityOf(repo, activeDays).active,
|
|
55
|
+
branch: repo?.branch ?? null,
|
|
56
|
+
firstScan: o.firstScan,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Log and notify what changed. Returns true if something was notified. */
|
|
61
|
+
export async function announce(outcomes, opts = {}) {
|
|
62
|
+
const news = toNews(outcomes, loadConfig().activeDays);
|
|
63
|
+
const at = new Date().toISOString();
|
|
64
|
+
const events = [];
|
|
65
|
+
for (const n of news) {
|
|
66
|
+
const base = {
|
|
67
|
+
at,
|
|
68
|
+
repo: n.repo?.label ?? n.path.split("/").pop() ?? n.path,
|
|
69
|
+
repoKey: n.repo?.key ?? `path:${n.path}`,
|
|
70
|
+
path: n.path,
|
|
71
|
+
branch: n.branch,
|
|
72
|
+
active: n.active !== false,
|
|
73
|
+
};
|
|
74
|
+
if (n.newFindings.length)
|
|
75
|
+
events.push({ ...base, kind: "new", ...(n.firstScan ? { firstScan: true } : {}), ...eventPackages(n.newFindings) });
|
|
76
|
+
const still = new Set((n.findings ?? []).map((f) => `${f.name}@${f.version}`));
|
|
77
|
+
const fixed = (n.resolvedFindings ?? []).filter((f) => !still.has(`${f.name}@${f.version}`));
|
|
78
|
+
if (fixed.length)
|
|
79
|
+
events.push({ ...base, kind: "resolved", ...eventPackages(fixed) });
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
appendEvents(events);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* the log is a convenience; never fail a scan because of it */
|
|
86
|
+
}
|
|
87
|
+
if (opts.notify === false)
|
|
88
|
+
return false;
|
|
89
|
+
const alerts = [buildAlert(news), buildResolvedAlert(news)].filter((a) => a.kind !== "none");
|
|
90
|
+
for (const a of alerts)
|
|
91
|
+
await deliver(a);
|
|
92
|
+
return alerts.length > 0;
|
|
93
|
+
}
|
|
94
|
+
/** Is a reminder due? Daytime only (9:00–21:00 local), at most once per period. */
|
|
95
|
+
export function reminderDue(every, last, now = new Date()) {
|
|
96
|
+
if (every === "off")
|
|
97
|
+
return false;
|
|
98
|
+
const hour = now.getHours();
|
|
99
|
+
if (hour < 9 || hour >= 21)
|
|
100
|
+
return false;
|
|
101
|
+
if (!last)
|
|
102
|
+
return true;
|
|
103
|
+
const gap = every === "daily" ? 20 * HOUR_MS : 6.5 * 24 * HOUR_MS;
|
|
104
|
+
return now.getTime() - Date.parse(last) >= gap;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Send the periodic reminder if it is due (or `force`). The input comes from api.getAttention().
|
|
108
|
+
* Returns whether a notification was sent.
|
|
109
|
+
*/
|
|
110
|
+
export async function maybeRemind(source, opts = {}) {
|
|
111
|
+
const cfg = loadConfig();
|
|
112
|
+
if (!opts.force && !reminderDue(cfg.remind, loadState().lastReminder))
|
|
113
|
+
return false;
|
|
114
|
+
// Computed only when due: the watcher calls this often.
|
|
115
|
+
const repos = typeof source === "function" ? source() : source;
|
|
116
|
+
const alert = buildReminder(repos);
|
|
117
|
+
// Mark the period as handled even when everything is fine, so a later finding waits its turn.
|
|
118
|
+
updateState({ lastReminder: new Date().toISOString() });
|
|
119
|
+
if (alert.kind === "none")
|
|
120
|
+
return false;
|
|
121
|
+
await deliver(alert);
|
|
122
|
+
appendEvents([
|
|
123
|
+
{
|
|
124
|
+
at: new Date().toISOString(),
|
|
125
|
+
kind: "reminder",
|
|
126
|
+
repo: alert.title.replace(/^selfguard: /, ""),
|
|
127
|
+
repoKey: "reminder",
|
|
128
|
+
path: null,
|
|
129
|
+
branch: null,
|
|
130
|
+
active: true,
|
|
131
|
+
total: repos.filter((r) => r.findings.some(needsAttention)).length,
|
|
132
|
+
packages: [],
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
return true;
|
|
136
|
+
}
|