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/dist/osv.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the OSV.dev API (https://google.github.io/osv.dev/api/).
|
|
3
|
+
* Only package names + versions are sent; nothing about your files or paths.
|
|
4
|
+
*/
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { atomicWrite, isObject, readJsonFile } from "./fsutil.js";
|
|
7
|
+
import { vulnCacheDir } from "./paths.js";
|
|
8
|
+
import { VERSION } from "./version.js";
|
|
9
|
+
const API = process.env.SELFGUARD_OSV_API ?? "https://api.osv.dev/v1";
|
|
10
|
+
const BATCH_SIZE = 1000; // documented max queries per querybatch request
|
|
11
|
+
const TIMEOUT_MS = 30_000;
|
|
12
|
+
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
13
|
+
const DETAIL_CONCURRENCY = 8;
|
|
14
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
15
|
+
async function request(method, path, body) {
|
|
16
|
+
const init = {
|
|
17
|
+
method,
|
|
18
|
+
headers: { "user-agent": `selfguard/${VERSION}`, accept: "application/json" },
|
|
19
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
20
|
+
redirect: "error",
|
|
21
|
+
};
|
|
22
|
+
if (body !== undefined) {
|
|
23
|
+
init.body = JSON.stringify(body);
|
|
24
|
+
init.headers["content-type"] = "application/json";
|
|
25
|
+
}
|
|
26
|
+
let lastErr;
|
|
27
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(`${API}${path}`, init);
|
|
30
|
+
if (res.status === 429 || res.status >= 500)
|
|
31
|
+
throw new Error(`OSV API ${res.status}`);
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw Object.assign(new Error(`OSV API ${res.status}`), { fatal: true });
|
|
34
|
+
const len = Number(res.headers.get("content-length") ?? 0);
|
|
35
|
+
if (len > MAX_RESPONSE_BYTES)
|
|
36
|
+
throw Object.assign(new Error("OSV response too large"), { fatal: true });
|
|
37
|
+
const text = await res.text();
|
|
38
|
+
if (text.length > MAX_RESPONSE_BYTES)
|
|
39
|
+
throw Object.assign(new Error("OSV response too large"), { fatal: true });
|
|
40
|
+
return JSON.parse(text);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
lastErr = err;
|
|
44
|
+
if (err.fatal)
|
|
45
|
+
break;
|
|
46
|
+
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
47
|
+
init.signal = AbortSignal.timeout(TIMEOUT_MS);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
51
|
+
}
|
|
52
|
+
function readRefs(result) {
|
|
53
|
+
if (!isObject(result))
|
|
54
|
+
return { refs: [] };
|
|
55
|
+
const refs = [];
|
|
56
|
+
if (Array.isArray(result.vulns)) {
|
|
57
|
+
for (const v of result.vulns) {
|
|
58
|
+
if (isObject(v) && typeof v.id === "string" && ID_RE.test(v.id)) {
|
|
59
|
+
refs.push({ id: v.id, modified: typeof v.modified === "string" ? v.modified : "" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const next = typeof result.next_page_token === "string" && result.next_page_token ? result.next_page_token : undefined;
|
|
64
|
+
return next ? { refs, next } : { refs };
|
|
65
|
+
}
|
|
66
|
+
/** Returns, for each input package (same order), the vulnerability ids OSV says affect it. */
|
|
67
|
+
export async function queryBatch(pkgs) {
|
|
68
|
+
const out = pkgs.map(() => []);
|
|
69
|
+
for (let start = 0; start < pkgs.length; start += BATCH_SIZE) {
|
|
70
|
+
// indexes into `pkgs` still to query, with optional page tokens
|
|
71
|
+
let pending = [];
|
|
72
|
+
for (let i = start; i < Math.min(start + BATCH_SIZE, pkgs.length); i++)
|
|
73
|
+
pending.push({ i });
|
|
74
|
+
for (let page = 0; pending.length > 0 && page < 20; page++) {
|
|
75
|
+
const queries = pending.map(({ i, token }) => ({
|
|
76
|
+
package: { ecosystem: "npm", name: pkgs[i].name },
|
|
77
|
+
version: pkgs[i].version,
|
|
78
|
+
...(token ? { page_token: token } : {}),
|
|
79
|
+
}));
|
|
80
|
+
const res = await request("POST", "/querybatch", { queries });
|
|
81
|
+
const results = isObject(res) && Array.isArray(res.results) ? res.results : [];
|
|
82
|
+
const next = [];
|
|
83
|
+
pending.forEach(({ i }, k) => {
|
|
84
|
+
const { refs, next: token } = readRefs(results[k]);
|
|
85
|
+
out[i].push(...refs);
|
|
86
|
+
if (token)
|
|
87
|
+
next.push({ i, token });
|
|
88
|
+
});
|
|
89
|
+
pending = next;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function cachePath(id) {
|
|
95
|
+
return join(vulnCacheDir(), `${id}.json`);
|
|
96
|
+
}
|
|
97
|
+
function asVuln(data) {
|
|
98
|
+
if (!isObject(data) || typeof data.id !== "string" || !ID_RE.test(data.id))
|
|
99
|
+
return null;
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
export function getCachedVuln(id) {
|
|
103
|
+
if (!ID_RE.test(id))
|
|
104
|
+
return null;
|
|
105
|
+
return readJsonFile(cachePath(id))?.vuln ?? null;
|
|
106
|
+
}
|
|
107
|
+
/** Fetch full advisories, reusing the local cache when `modified` hasn't changed. */
|
|
108
|
+
export async function getVulns(refs) {
|
|
109
|
+
const result = new Map();
|
|
110
|
+
const toFetch = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
for (const { id, modified } of refs) {
|
|
113
|
+
if (seen.has(id))
|
|
114
|
+
continue;
|
|
115
|
+
seen.add(id);
|
|
116
|
+
const cached = getCachedVuln(id);
|
|
117
|
+
if (cached && modified && cached.modified === modified)
|
|
118
|
+
result.set(id, cached);
|
|
119
|
+
else
|
|
120
|
+
toFetch.push(id);
|
|
121
|
+
}
|
|
122
|
+
let cursor = 0;
|
|
123
|
+
const worker = async () => {
|
|
124
|
+
while (cursor < toFetch.length) {
|
|
125
|
+
const id = toFetch[cursor++];
|
|
126
|
+
try {
|
|
127
|
+
const vuln = asVuln(await request("GET", `/vulns/${encodeURIComponent(id)}`));
|
|
128
|
+
if (!vuln || vuln.id !== id)
|
|
129
|
+
continue;
|
|
130
|
+
atomicWrite(cachePath(id), JSON.stringify({ fetchedAt: new Date().toISOString(), vuln }));
|
|
131
|
+
result.set(id, vuln);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
const stale = getCachedVuln(id); // offline / API hiccup: fall back to what we had
|
|
135
|
+
if (stale)
|
|
136
|
+
result.set(id, stale);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
await Promise.all(Array.from({ length: Math.min(DETAIL_CONCURRENCY, toFetch.length) }, worker));
|
|
141
|
+
return result;
|
|
142
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** Root of all local state. Override with SELFGUARD_HOME (used by tests). */
|
|
4
|
+
export function stateRoot() {
|
|
5
|
+
const override = process.env.SELFGUARD_HOME;
|
|
6
|
+
if (override && override.trim() !== "")
|
|
7
|
+
return override;
|
|
8
|
+
return join(homedir(), ".selfguard");
|
|
9
|
+
}
|
|
10
|
+
export const reposDir = () => join(stateRoot(), "repos");
|
|
11
|
+
export const vulnCacheDir = () => join(stateRoot(), "cache", "vulns");
|
|
12
|
+
export const locksDir = () => join(stateRoot(), "locks");
|
|
13
|
+
export const logFile = () => join(stateRoot(), "selfguard.log");
|
package/dist/repoid.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identify which repository a project belongs to, so worktrees and separate clones of
|
|
3
|
+
* the same repo are grouped. Reads a few small files under .git as data; never runs git.
|
|
4
|
+
*/
|
|
5
|
+
import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
const MAX_FILE = 64 * 1024;
|
|
9
|
+
function readSmall(file) {
|
|
10
|
+
let fd;
|
|
11
|
+
try {
|
|
12
|
+
if (!statSync(file).isFile())
|
|
13
|
+
return null;
|
|
14
|
+
fd = openSync(file, "r");
|
|
15
|
+
const buf = Buffer.alloc(MAX_FILE);
|
|
16
|
+
const n = readSync(fd, buf, 0, MAX_FILE, 0);
|
|
17
|
+
return buf.subarray(0, n).toString("utf8");
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
if (fd !== undefined)
|
|
24
|
+
closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isDir(p) {
|
|
28
|
+
try {
|
|
29
|
+
return statSync(p).isDirectory();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Normalize a git remote to "host/path" (lowercase host, no scheme/user/.git). */
|
|
36
|
+
export function normalizeRemote(url) {
|
|
37
|
+
const u = url.trim();
|
|
38
|
+
let host;
|
|
39
|
+
let path;
|
|
40
|
+
const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(.+)$/.exec(u); // git@host:owner/repo.git
|
|
41
|
+
if (scp && !/^[a-z]+:\/\//i.test(u)) {
|
|
42
|
+
host = scp[1];
|
|
43
|
+
path = scp[2];
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(u);
|
|
48
|
+
if (!parsed.hostname)
|
|
49
|
+
return null;
|
|
50
|
+
host = parsed.hostname;
|
|
51
|
+
path = parsed.pathname;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
58
|
+
if (!path)
|
|
59
|
+
return null;
|
|
60
|
+
return `${host.toLowerCase()}/${path}`;
|
|
61
|
+
}
|
|
62
|
+
/** Minimal INI read of [remote "origin"] url (falls back to the first remote). */
|
|
63
|
+
export function remoteFromConfig(config) {
|
|
64
|
+
let section = "";
|
|
65
|
+
let first = null;
|
|
66
|
+
for (const raw of config.split(/\r?\n/)) {
|
|
67
|
+
const line = raw.trim();
|
|
68
|
+
const sec = /^\[\s*remote\s+"([^"]*)"\s*\]$/i.exec(line);
|
|
69
|
+
if (sec) {
|
|
70
|
+
section = sec[1];
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (line.startsWith("[")) {
|
|
74
|
+
section = "";
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!section)
|
|
78
|
+
continue;
|
|
79
|
+
const kv = /^url\s*=\s*(.+)$/i.exec(line);
|
|
80
|
+
if (!kv)
|
|
81
|
+
continue;
|
|
82
|
+
const url = kv[1].trim().replace(/^"(.*)"$/, "$1");
|
|
83
|
+
if (section === "origin")
|
|
84
|
+
return url;
|
|
85
|
+
first ??= url;
|
|
86
|
+
}
|
|
87
|
+
return first;
|
|
88
|
+
}
|
|
89
|
+
function branchFrom(head) {
|
|
90
|
+
if (!head)
|
|
91
|
+
return null;
|
|
92
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/m.exec(head);
|
|
93
|
+
if (ref)
|
|
94
|
+
return ref[1].trim();
|
|
95
|
+
const sha = /^([0-9a-f]{7,64})\s*$/im.exec(head);
|
|
96
|
+
return sha ? sha[1].slice(0, 8) : null;
|
|
97
|
+
}
|
|
98
|
+
export function repoIdentity(projectRoot) {
|
|
99
|
+
const home = homedir();
|
|
100
|
+
let dir = resolve(projectRoot);
|
|
101
|
+
for (let i = 0; i < 40; i++) {
|
|
102
|
+
const dotgit = join(dir, ".git");
|
|
103
|
+
let gitDir = null;
|
|
104
|
+
let worktree = false;
|
|
105
|
+
if (isDir(dotgit)) {
|
|
106
|
+
gitDir = dotgit;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const pointer = readSmall(dotgit);
|
|
110
|
+
const m = pointer ? /^gitdir:\s*(.+)$/m.exec(pointer) : null;
|
|
111
|
+
if (m) {
|
|
112
|
+
const target = m[1].trim();
|
|
113
|
+
gitDir = isAbsolute(target) ? target : resolve(dir, target);
|
|
114
|
+
worktree = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (gitDir) {
|
|
118
|
+
// Linked worktrees point at <common>/.git/worktrees/<name>; "commondir" leads back.
|
|
119
|
+
const commondir = worktree ? readSmall(join(gitDir, "commondir"))?.trim() : undefined;
|
|
120
|
+
const commonDir = commondir ? resolve(gitDir, commondir) : gitDir;
|
|
121
|
+
const remote = remoteFromConfig(readSmall(join(commonDir, "config")) ?? "");
|
|
122
|
+
const normalized = remote ? normalizeRemote(remote) : null;
|
|
123
|
+
let base;
|
|
124
|
+
try {
|
|
125
|
+
base = normalized ?? `local:${realpathSync(commonDir)}`;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
base = `local:${commonDir}`;
|
|
129
|
+
}
|
|
130
|
+
const sub = relative(dir, resolve(projectRoot)).split(sep).join("/");
|
|
131
|
+
const repoName = normalized ? normalized.slice(normalized.indexOf("/") + 1) : basename(dirname(commonDir));
|
|
132
|
+
return {
|
|
133
|
+
key: sub ? `${base}#${sub}` : base,
|
|
134
|
+
label: sub ? `${repoName}/${sub}` : repoName,
|
|
135
|
+
branch: branchFrom(readSmall(join(gitDir, "HEAD"))),
|
|
136
|
+
worktree,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const parent = dirname(dir);
|
|
140
|
+
if (parent === dir || dir === home)
|
|
141
|
+
return null;
|
|
142
|
+
dir = parent;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { severityRank } from "./match.js";
|
|
3
|
+
import { compare, isValid } from "./semver.js";
|
|
4
|
+
import { clean } from "./sanitize.js";
|
|
5
|
+
function useColor(stream) {
|
|
6
|
+
return Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
7
|
+
}
|
|
8
|
+
export function paint(stream) {
|
|
9
|
+
const on = useColor(stream);
|
|
10
|
+
const wrap = (open, close) => (s) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : s);
|
|
11
|
+
return {
|
|
12
|
+
bold: wrap(1, 22),
|
|
13
|
+
dim: wrap(2, 22),
|
|
14
|
+
red: wrap(31, 39),
|
|
15
|
+
yellow: wrap(33, 39),
|
|
16
|
+
green: wrap(32, 39),
|
|
17
|
+
magenta: wrap(35, 39),
|
|
18
|
+
cyan: wrap(36, 39),
|
|
19
|
+
inverseRed: wrap(41, 49),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function severityLabel(s, c) {
|
|
23
|
+
switch (s) {
|
|
24
|
+
case "malicious":
|
|
25
|
+
return c.inverseRed(c.bold(" MALICIOUS "));
|
|
26
|
+
case "critical":
|
|
27
|
+
return c.red(c.bold("CRITICAL"));
|
|
28
|
+
case "high":
|
|
29
|
+
return c.red("HIGH");
|
|
30
|
+
case "moderate":
|
|
31
|
+
return c.yellow("MODERATE");
|
|
32
|
+
case "low":
|
|
33
|
+
return c.dim("LOW");
|
|
34
|
+
default:
|
|
35
|
+
return c.dim("UNKNOWN");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function tildify(p) {
|
|
39
|
+
const home = homedir();
|
|
40
|
+
return p === home || p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
|
|
41
|
+
}
|
|
42
|
+
export function countBySeverity(findings) {
|
|
43
|
+
const counts = {};
|
|
44
|
+
for (const f of findings)
|
|
45
|
+
counts[f.severity] = (counts[f.severity] ?? 0) + 1;
|
|
46
|
+
return counts;
|
|
47
|
+
}
|
|
48
|
+
export function summarizeCounts(findings) {
|
|
49
|
+
const counts = countBySeverity(findings);
|
|
50
|
+
const order = ["malicious", "critical", "high", "moderate", "low", "unknown"];
|
|
51
|
+
return order
|
|
52
|
+
.filter((s) => counts[s])
|
|
53
|
+
.map((s) => `${counts[s]} ${s}`)
|
|
54
|
+
.join(", ");
|
|
55
|
+
}
|
|
56
|
+
export function renderFindings(rec, stream, verbose = false) {
|
|
57
|
+
const c = paint(stream);
|
|
58
|
+
const lines = [];
|
|
59
|
+
const title = `${c.bold(tildify(rec.path))} ${c.dim(`${rec.deps.length} packages`)}`;
|
|
60
|
+
lines.push(title);
|
|
61
|
+
if (rec.error)
|
|
62
|
+
lines.push(` ${c.yellow("!")} ${clean(rec.error)}`);
|
|
63
|
+
if (rec.skipped > 0)
|
|
64
|
+
lines.push(c.dim(` ${rec.skipped} entries skipped (git/file/link deps)`));
|
|
65
|
+
if (rec.findings.length === 0) {
|
|
66
|
+
if (!rec.error)
|
|
67
|
+
lines.push(` ${c.green("✓")} no known vulnerabilities`);
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
const groups = groupByPackage(rec.findings);
|
|
71
|
+
lines.push(` ${groups.length} vulnerable package(s), ${rec.findings.length} advisories: ${summarizeCounts(rec.findings)}`);
|
|
72
|
+
for (const g of groups) {
|
|
73
|
+
const pkg = `${clean(g.name, 120)}@${clean(g.version, 60)}`;
|
|
74
|
+
const tags = [g.direct ? "direct" : "transitive", g.dev ? "dev" : ""].filter(Boolean).join(", ");
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push(` ${severityLabel(g.worst, c)} ${c.bold(pkg)} ${c.dim(`(${tags})`)}`);
|
|
77
|
+
if (g.worst === "malicious") {
|
|
78
|
+
lines.push(` ${c.red("→ remove it now and rotate any credentials available on this machine/CI")}`);
|
|
79
|
+
}
|
|
80
|
+
else if (g.upgradeTo) {
|
|
81
|
+
const partial = g.unfixed > 0 ? c.yellow(` (${g.unfixed} advisory(ies) have no fix yet)`) : "";
|
|
82
|
+
lines.push(` ${c.green(`→ upgrade to ${clean(g.upgradeTo, 60)} or later`)}${partial}`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
lines.push(` ${c.yellow("→ no fixed version published; consider replacing it")}`);
|
|
86
|
+
}
|
|
87
|
+
const shown = verbose ? g.findings : g.findings.slice(0, 3);
|
|
88
|
+
for (const f of shown) {
|
|
89
|
+
lines.push(` ${c.dim("•")} ${clean(f.id, 64)} ${c.dim(f.severity)}${f.summary ? ` ${clean(f.summary, 90)}` : ""}`);
|
|
90
|
+
}
|
|
91
|
+
if (shown.length < g.findings.length)
|
|
92
|
+
lines.push(c.dim(` • …and ${g.findings.length - shown.length} more (--verbose)`));
|
|
93
|
+
}
|
|
94
|
+
lines.push("");
|
|
95
|
+
lines.push(c.dim(" Details: https://osv.dev/vulnerability/<ID>"));
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
export function worstSeverity(findings) {
|
|
99
|
+
const order = ["malicious", "critical", "high", "moderate", "low", "unknown"];
|
|
100
|
+
for (const s of order)
|
|
101
|
+
if (findings.some((f) => f.severity === s))
|
|
102
|
+
return s;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
export function groupByPackage(findings) {
|
|
106
|
+
const map = new Map();
|
|
107
|
+
for (const f of findings) {
|
|
108
|
+
const key = `${f.name}@${f.version}`;
|
|
109
|
+
let g = map.get(key);
|
|
110
|
+
if (!g) {
|
|
111
|
+
g = { name: f.name, version: f.version, direct: f.direct, dev: f.dev, worst: f.severity, findings: [], unfixed: 0 };
|
|
112
|
+
map.set(key, g);
|
|
113
|
+
}
|
|
114
|
+
g.findings.push(f);
|
|
115
|
+
if (severityRank(f.severity) < severityRank(g.worst))
|
|
116
|
+
g.worst = f.severity;
|
|
117
|
+
if (f.fixedIn && isValid(f.fixedIn)) {
|
|
118
|
+
if (!g.upgradeTo || compare(f.fixedIn, g.upgradeTo) > 0)
|
|
119
|
+
g.upgradeTo = f.fixedIn;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
g.unfixed++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return [...map.values()].sort((a, b) => severityRank(a.worst) - severityRank(b.worst) || Number(b.direct) - Number(a.direct) || a.name.localeCompare(b.name));
|
|
126
|
+
}
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything we print comes from untrusted sources (lockfiles, advisory text).
|
|
3
|
+
* Strip control characters so nobody can inject terminal escape sequences
|
|
4
|
+
* (cursor movement, OSC 8 links, title changes, clipboard writes...) and
|
|
5
|
+
* bidirectional overrides ("Trojan Source" style spoofing).
|
|
6
|
+
*/
|
|
7
|
+
const CONTROL = new RegExp("[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]", "g");
|
|
8
|
+
export function clean(input, maxLen = 300, opts = {}) {
|
|
9
|
+
const stripped = String(input ?? "").replace(CONTROL, "");
|
|
10
|
+
const s = (opts.multiline ? stripped.replace(/\n{3,}/g, "\n\n") : stripped.replace(/\s*\n\s*/g, " ")).trim();
|
|
11
|
+
return s.length > maxLen ? `${s.slice(0, maxLen - 1)}…` : s;
|
|
12
|
+
}
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { sha256 } from "./fsutil.js";
|
|
4
|
+
import { isSupported, parseLockfile } from "./lockfile/index.js";
|
|
5
|
+
import { evaluate, severityOf, severityRank } from "./match.js";
|
|
6
|
+
import { getVulns, queryBatch } from "./osv.js";
|
|
7
|
+
import { clean } from "./sanitize.js";
|
|
8
|
+
import { findingKey, loadRepo } from "./store.js";
|
|
9
|
+
const MAX_LOCKFILE_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
export function lockState(lockfile) {
|
|
11
|
+
try {
|
|
12
|
+
const st = statSync(lockfile);
|
|
13
|
+
if (!st.isFile() || st.size > MAX_LOCKFILE_BYTES)
|
|
14
|
+
return null;
|
|
15
|
+
return { hash: sha256(readFileSync(lockfile)), mtimeMs: st.mtimeMs, size: st.size };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Cheap check first (mtime+size), hash only if those changed. */
|
|
22
|
+
export function lockChanged(rec, project) {
|
|
23
|
+
if (rec.lockfile !== project.lockfile)
|
|
24
|
+
return true;
|
|
25
|
+
if (!project.lockfile)
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
const st = statSync(project.lockfile);
|
|
29
|
+
if (st.mtimeMs === rec.lockMtimeMs && st.size === rec.lockSize)
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return lockState(project.lockfile)?.hash !== rec.lockHash;
|
|
36
|
+
}
|
|
37
|
+
/** Parse a project's lockfile into a fresh (findings-less) record. Never executes project code. */
|
|
38
|
+
function inventory(project) {
|
|
39
|
+
const previous = loadRepo(project.root);
|
|
40
|
+
const now = new Date().toISOString();
|
|
41
|
+
const record = {
|
|
42
|
+
schema: 1,
|
|
43
|
+
path: project.root,
|
|
44
|
+
lockfile: project.lockfile,
|
|
45
|
+
lockType: project.lockType,
|
|
46
|
+
lockHash: null,
|
|
47
|
+
lockMtimeMs: null,
|
|
48
|
+
lockSize: null,
|
|
49
|
+
registeredAt: previous?.registeredAt ?? now,
|
|
50
|
+
scannedAt: null,
|
|
51
|
+
deps: [],
|
|
52
|
+
skipped: 0,
|
|
53
|
+
findings: [],
|
|
54
|
+
};
|
|
55
|
+
if (!project.lockfile || !project.lockType) {
|
|
56
|
+
record.error = "no lockfile found (run your package manager's install to create one)";
|
|
57
|
+
record.scannedAt = now;
|
|
58
|
+
return { record, previous };
|
|
59
|
+
}
|
|
60
|
+
const st = lockState(project.lockfile);
|
|
61
|
+
if (!st) {
|
|
62
|
+
record.error = "lockfile unreadable or too large";
|
|
63
|
+
record.scannedAt = now;
|
|
64
|
+
return { record, previous };
|
|
65
|
+
}
|
|
66
|
+
Object.assign(record, { lockHash: st.hash, lockMtimeMs: st.mtimeMs, lockSize: st.size });
|
|
67
|
+
if (!isSupported(project.lockType, project.lockfile)) {
|
|
68
|
+
record.error = "bun.lockb (binary) is not supported; run `bun install --save-text-lockfile` to create bun.lock";
|
|
69
|
+
record.scannedAt = now;
|
|
70
|
+
return { record, previous };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
let manifest;
|
|
74
|
+
try {
|
|
75
|
+
manifest = JSON.parse(readFileSync(join(project.root, "package.json"), "utf8"));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
manifest = undefined;
|
|
79
|
+
}
|
|
80
|
+
const parsed = parseLockfile(project.lockType, readFileSync(project.lockfile, "utf8"), manifest);
|
|
81
|
+
record.deps = parsed.deps;
|
|
82
|
+
record.skipped = parsed.skipped;
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
record.error = `could not parse lockfile: ${clean(err.message, 200)}`;
|
|
86
|
+
record.scannedAt = now;
|
|
87
|
+
}
|
|
88
|
+
return { record, previous };
|
|
89
|
+
}
|
|
90
|
+
function buildFindings(deps, refsPerDep, vulns, previous, now) {
|
|
91
|
+
const prevSeen = new Map((previous?.findings ?? []).map((f) => [findingKey(f), f.firstSeen]));
|
|
92
|
+
const findings = [];
|
|
93
|
+
for (const dep of deps) {
|
|
94
|
+
const ids = refsPerDep.get(`${dep.name}@${dep.version}`) ?? [];
|
|
95
|
+
const reported = new Set();
|
|
96
|
+
for (const id of ids) {
|
|
97
|
+
const vuln = vulns.get(id);
|
|
98
|
+
if (vuln?.withdrawn)
|
|
99
|
+
continue;
|
|
100
|
+
// Collapse duplicates published under several ids (e.g. GHSA + CVE alias).
|
|
101
|
+
if (vuln?.aliases?.some((a) => reported.has(a)) || reported.has(id))
|
|
102
|
+
continue;
|
|
103
|
+
reported.add(id);
|
|
104
|
+
const ev = vuln ? evaluate(vuln, dep.name, dep.version) : { affected: true };
|
|
105
|
+
const f = {
|
|
106
|
+
id,
|
|
107
|
+
aliases: (vuln?.aliases ?? []).map((a) => clean(a, 64)).slice(0, 10),
|
|
108
|
+
severity: vuln ? severityOf(vuln) : id.startsWith("MAL-") ? "malicious" : "unknown",
|
|
109
|
+
summary: clean(vuln?.summary ?? vuln?.details ?? "", 200),
|
|
110
|
+
name: dep.name,
|
|
111
|
+
version: dep.version,
|
|
112
|
+
direct: dep.direct,
|
|
113
|
+
dev: dep.dev,
|
|
114
|
+
firstSeen: "",
|
|
115
|
+
};
|
|
116
|
+
if (ev.fixedIn)
|
|
117
|
+
f.fixedIn = ev.fixedIn;
|
|
118
|
+
f.firstSeen = prevSeen.get(findingKey(f)) ?? now;
|
|
119
|
+
findings.push(f);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || Number(b.direct) - Number(a.direct) || a.name.localeCompare(b.name));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Scan one or many projects with a single deduplicated OSV query.
|
|
126
|
+
* Records are returned, not saved: the caller decides.
|
|
127
|
+
*/
|
|
128
|
+
export async function scanProjects(projects) {
|
|
129
|
+
const inventories = projects.map(inventory);
|
|
130
|
+
const unique = new Map();
|
|
131
|
+
for (const { record } of inventories) {
|
|
132
|
+
if (record.error)
|
|
133
|
+
continue;
|
|
134
|
+
for (const d of record.deps)
|
|
135
|
+
unique.set(`${d.name}@${d.version}`, { name: d.name, version: d.version });
|
|
136
|
+
}
|
|
137
|
+
const queries = [...unique.values()];
|
|
138
|
+
let refsPerDep = new Map();
|
|
139
|
+
let vulns = new Map();
|
|
140
|
+
let networkError;
|
|
141
|
+
if (queries.length > 0) {
|
|
142
|
+
try {
|
|
143
|
+
const refs = await queryBatch(queries);
|
|
144
|
+
refsPerDep = new Map(queries.map((q, i) => [`${q.name}@${q.version}`, refs[i].map((r) => r.id)]));
|
|
145
|
+
vulns = await getVulns(refs.flat());
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
networkError = `could not reach OSV: ${clean(err.message, 200)}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const now = new Date().toISOString();
|
|
152
|
+
return inventories.map(({ record, previous }) => {
|
|
153
|
+
if (networkError && !record.error) {
|
|
154
|
+
// Keep the last known findings rather than pretending everything is fine.
|
|
155
|
+
record.error = networkError;
|
|
156
|
+
record.findings = previous?.findings ?? [];
|
|
157
|
+
record.scannedAt = previous?.scannedAt ?? null;
|
|
158
|
+
return { record, newFindings: [], firstScan: previous === null };
|
|
159
|
+
}
|
|
160
|
+
if (!record.error) {
|
|
161
|
+
record.findings = buildFindings(record.deps, refsPerDep, vulns, previous, now);
|
|
162
|
+
record.scannedAt = now;
|
|
163
|
+
}
|
|
164
|
+
const before = new Set((previous?.findings ?? []).map(findingKey));
|
|
165
|
+
const newFindings = record.findings.filter((f) => !before.has(findingKey(f)));
|
|
166
|
+
return { record, newFindings, firstScan: previous === null };
|
|
167
|
+
});
|
|
168
|
+
}
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Periodic `selfguard sync` via a per-user launchd agent (macOS). */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { homedir, userInfo } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { atomicWrite } from "./fsutil.js";
|
|
7
|
+
import { cliEntry } from "./hook.js";
|
|
8
|
+
import { logFile } from "./paths.js";
|
|
9
|
+
const LABEL = "dev.selfguard.sync";
|
|
10
|
+
const plistPath = () => join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
11
|
+
const domain = () => `gui/${userInfo().uid}`;
|
|
12
|
+
function xml(s) {
|
|
13
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
14
|
+
}
|
|
15
|
+
export function parseInterval(input) {
|
|
16
|
+
const m = /^(\d+)\s*(m|h|d)$/.exec(input.trim());
|
|
17
|
+
if (!m)
|
|
18
|
+
throw new Error(`invalid interval "${input}" (use e.g. 30m, 6h, 1d)`);
|
|
19
|
+
const n = Number(m[1]) * { m: 60, h: 3600, d: 86400 }[m[2]];
|
|
20
|
+
if (n < 15 * 60)
|
|
21
|
+
throw new Error("minimum interval is 15m");
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
function assertMac() {
|
|
25
|
+
if (process.platform !== "darwin")
|
|
26
|
+
throw new Error("scheduling is only implemented for macOS (launchd) for now");
|
|
27
|
+
}
|
|
28
|
+
export function scheduleOn(seconds) {
|
|
29
|
+
assertMac();
|
|
30
|
+
const args = [process.execPath, cliEntry(), "sync", "--background"];
|
|
31
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
32
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
33
|
+
<plist version="1.0">
|
|
34
|
+
<dict>
|
|
35
|
+
<key>Label</key><string>${LABEL}</string>
|
|
36
|
+
<key>ProgramArguments</key>
|
|
37
|
+
<array>
|
|
38
|
+
${args.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
|
|
39
|
+
</array>
|
|
40
|
+
<key>StartInterval</key><integer>${seconds}</integer>
|
|
41
|
+
<key>RunAtLoad</key><true/>
|
|
42
|
+
<key>ProcessType</key><string>Background</string>
|
|
43
|
+
<key>LowPriorityIO</key><true/>
|
|
44
|
+
<key>StandardOutPath</key><string>${xml(logFile())}</string>
|
|
45
|
+
<key>StandardErrorPath</key><string>${xml(logFile())}</string>
|
|
46
|
+
</dict>
|
|
47
|
+
</plist>
|
|
48
|
+
`;
|
|
49
|
+
try {
|
|
50
|
+
execFileSync("/bin/launchctl", ["bootout", `${domain()}/${LABEL}`], { stdio: "ignore" });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* not loaded */
|
|
54
|
+
}
|
|
55
|
+
atomicWrite(plistPath(), plist);
|
|
56
|
+
execFileSync("/bin/launchctl", ["bootstrap", domain(), plistPath()], { stdio: "ignore" });
|
|
57
|
+
return plistPath();
|
|
58
|
+
}
|
|
59
|
+
export function scheduleOff() {
|
|
60
|
+
assertMac();
|
|
61
|
+
try {
|
|
62
|
+
execFileSync("/bin/launchctl", ["bootout", `${domain()}/${LABEL}`], { stdio: "ignore" });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* not loaded */
|
|
66
|
+
}
|
|
67
|
+
const existed = existsSync(plistPath());
|
|
68
|
+
rmSync(plistPath(), { force: true });
|
|
69
|
+
return existed;
|
|
70
|
+
}
|
|
71
|
+
export function scheduleStatus() {
|
|
72
|
+
assertMac();
|
|
73
|
+
let loaded = false;
|
|
74
|
+
try {
|
|
75
|
+
execFileSync("/bin/launchctl", ["print", `${domain()}/${LABEL}`], { stdio: "ignore" });
|
|
76
|
+
loaded = true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
loaded = false;
|
|
80
|
+
}
|
|
81
|
+
return { installed: existsSync(plistPath()), loaded, plist: plistPath() };
|
|
82
|
+
}
|