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/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,136 @@
|
|
|
1
1
|
# selfguard
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
```
|
|
24
|
+
|
|
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.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm install -g selfguard
|
|
58
|
+
selfguard setup
|
|
59
|
+
```
|
|
60
|
+
|
|
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:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
selfguard ui
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
Where do you keep your code?
|
|
71
|
+
selfguard only looks inside the folders you pick, nothing else on your disk.
|
|
72
|
+
|
|
73
|
+
[x] ~/code 42 projects depth 5
|
|
74
|
+
› [ ] ~/work 17 projects depth 5
|
|
75
|
+
+ add another folder…
|
|
76
|
+
|
|
77
|
+
↑/↓ move · space select · ←/→ depth · a add folder · enter continue · q quit
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or scan a single project without setting anything up:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
$ selfguard scan
|
|
84
|
+
~/code/payments-api 412 packages
|
|
85
|
+
3 vulnerable package(s), 9 advisories: 1 critical, 4 high, 4 moderate
|
|
86
|
+
|
|
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
|
|
90
|
+
|
|
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
|
+
…
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
| | |
|
|
100
|
+
|---|---|
|
|
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 |
|
|
107
|
+
|
|
108
|
+
## How it compares
|
|
109
|
+
|
|
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 |
|
|
119
|
+
|
|
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.
|
|
122
|
+
|
|
123
|
+
## Privacy
|
|
124
|
+
|
|
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).
|
|
128
|
+
|
|
129
|
+
## Contributing
|
|
130
|
+
|
|
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).
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
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
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
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 relevant = news.map((n) => n.active === false ? { ...n, newFindings: n.newFindings.filter((f) => f.severity === "malicious") } : n);
|
|
47
|
+
const affected = byRepo(relevant);
|
|
48
|
+
if (affected.length === 0)
|
|
49
|
+
return { kind: "none" };
|
|
50
|
+
const all = affected.flatMap((n) => n.newFindings);
|
|
51
|
+
const malicious = affected.flatMap((n) => n.newFindings.filter((f) => f.severity === "malicious").map((f) => ({ f, path: n.path })));
|
|
52
|
+
if (malicious.length > 0) {
|
|
53
|
+
const { f, path } = malicious[0];
|
|
54
|
+
const group = affected.find((n) => n.path === path);
|
|
55
|
+
const where = affected.length === 1 ? group.label : `${affected.length} projects`;
|
|
56
|
+
const others = malicious.length > 1 ? ` (+${malicious.length - 1} more malicious)` : "";
|
|
57
|
+
return {
|
|
58
|
+
kind: "critical",
|
|
59
|
+
title: `⛔ Malicious package in ${where}`,
|
|
60
|
+
subtitle: `${f.name}@${f.version} · ${f.id}`,
|
|
61
|
+
body: "Remove it now and rotate credentials on this machine.",
|
|
62
|
+
dialogTitle: `selfguard: malicious package in ${where}`,
|
|
63
|
+
dialogMessage: `${f.name}@${f.version} is a known malicious package${others}.\n\n` +
|
|
64
|
+
`Project: ${path}${group.checkouts > 1 ? ` (and ${group.checkouts - 1} other checkout(s) of ${group.label})` : ""}\n\n` +
|
|
65
|
+
"1. Remove it (or pin a safe version) and reinstall.\n" +
|
|
66
|
+
"2. Rotate credentials this machine or CI could reach: npm tokens, cloud keys, SSH keys, .env secrets.\n" +
|
|
67
|
+
"3. Check the advisory for what the payload does.",
|
|
68
|
+
url: `https://osv.dev/vulnerability/${encodeURIComponent(f.id)}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const worst = LABEL[worstSeverity(all) ?? "unknown"];
|
|
72
|
+
if (affected.length === 1) {
|
|
73
|
+
const n = affected[0];
|
|
74
|
+
const pkgs = groupByPackage(n.newFindings).length;
|
|
75
|
+
return {
|
|
76
|
+
kind: "banner",
|
|
77
|
+
title: `selfguard: ${n.label}${n.checkouts > 1 ? ` (${n.checkouts} checkouts)` : ""}`,
|
|
78
|
+
subtitle: `${plural(pkgs, "vulnerable package")} · worst: ${worst}`,
|
|
79
|
+
body: listFixes(n.newFindings),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const pkgs = affected.reduce((sum, n) => sum + groupByPackage(n.newFindings).length, 0);
|
|
83
|
+
const names = affected.map((n) => n.label);
|
|
84
|
+
return {
|
|
85
|
+
kind: "banner",
|
|
86
|
+
title: `selfguard: ${affected.length} projects affected`,
|
|
87
|
+
subtitle: `${plural(pkgs, "vulnerable package")} · worst: ${worst}`,
|
|
88
|
+
body: `${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}. Run: selfguard status`,
|
|
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
|
+
}
|
|
183
|
+
export async function deliver(alert) {
|
|
184
|
+
if (alert.kind === "none")
|
|
185
|
+
return;
|
|
186
|
+
await notify(alert.title, alert.body, alert.subtitle);
|
|
187
|
+
if (alert.kind === "critical")
|
|
188
|
+
await criticalDialog(alert.dialogTitle, alert.dialogMessage, alert.url);
|
|
189
|
+
}
|
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
|
+
}
|