@matthesketh/fleet 1.0.0 → 1.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 +27 -4
- package/dist/cli.js +8 -0
- package/dist/commands/deps.d.ts +1 -0
- package/dist/commands/deps.js +223 -0
- package/dist/commands/motd.d.ts +1 -0
- package/dist/commands/motd.js +10 -0
- package/dist/core/deps/actors/pr-creator.d.ts +14 -0
- package/dist/core/deps/actors/pr-creator.js +103 -0
- package/dist/core/deps/cache.d.ts +5 -0
- package/dist/core/deps/cache.js +28 -0
- package/dist/core/deps/collectors/composer.d.ts +12 -0
- package/dist/core/deps/collectors/composer.js +70 -0
- package/dist/core/deps/collectors/docker-image.d.ts +18 -0
- package/dist/core/deps/collectors/docker-image.js +132 -0
- package/dist/core/deps/collectors/docker-running.d.ts +17 -0
- package/dist/core/deps/collectors/docker-running.js +55 -0
- package/dist/core/deps/collectors/eol.d.ts +16 -0
- package/dist/core/deps/collectors/eol.js +139 -0
- package/dist/core/deps/collectors/github-pr.d.ts +8 -0
- package/dist/core/deps/collectors/github-pr.js +40 -0
- package/dist/core/deps/collectors/npm.d.ts +12 -0
- package/dist/core/deps/collectors/npm.js +63 -0
- package/dist/core/deps/collectors/pip.d.ts +15 -0
- package/dist/core/deps/collectors/pip.js +94 -0
- package/dist/core/deps/collectors/vulnerability.d.ts +9 -0
- package/dist/core/deps/collectors/vulnerability.js +102 -0
- package/dist/core/deps/config.d.ts +6 -0
- package/dist/core/deps/config.js +55 -0
- package/dist/core/deps/reporters/cli.d.ts +4 -0
- package/dist/core/deps/reporters/cli.js +123 -0
- package/dist/core/deps/reporters/motd.d.ts +3 -0
- package/dist/core/deps/reporters/motd.js +64 -0
- package/dist/core/deps/reporters/telegram.d.ts +6 -0
- package/dist/core/deps/reporters/telegram.js +106 -0
- package/dist/core/deps/scanner.d.ts +4 -0
- package/dist/core/deps/scanner.js +89 -0
- package/dist/core/deps/severity.d.ts +6 -0
- package/dist/core/deps/severity.js +45 -0
- package/dist/core/deps/types.d.ts +64 -0
- package/dist/core/deps/types.js +1 -0
- package/dist/mcp/deps-tools.d.ts +2 -0
- package/dist/mcp/deps-tools.js +81 -0
- package/dist/mcp/server.js +2 -0
- package/dist/templates/motd.d.ts +1 -0
- package/dist/templates/motd.js +7 -0
- package/dist/tui/components/AppList.js +1 -1
- package/dist/tui/components/Confirm.js +3 -4
- package/dist/tui/components/Header.js +37 -8
- package/dist/tui/components/KeyHint.js +4 -5
- package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
- package/dist/tui/hooks/use-terminal-size.js +1 -0
- package/dist/tui/router.js +81 -9
- package/dist/tui/state.js +15 -0
- package/dist/tui/tests/flicker.test.d.ts +1 -0
- package/dist/tui/tests/flicker.test.js +105 -0
- package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
- package/dist/tui/tests/keyboard-integration.test.js +117 -0
- package/dist/tui/tests/test-app.d.ts +4 -0
- package/dist/tui/tests/test-app.js +79 -0
- package/dist/tui/types.d.ts +13 -0
- package/dist/tui/views/AppDetail.js +41 -26
- package/dist/tui/views/Dashboard.js +34 -9
- package/dist/tui/views/HealthView.js +36 -12
- package/dist/tui/views/LogsView.js +14 -9
- package/dist/tui/views/SecretEdit.js +8 -4
- package/dist/tui/views/SecretsView.js +49 -36
- package/package.json +17 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { severityFromCvss } from '../severity.js';
|
|
4
|
+
export class VulnerabilityCollector {
|
|
5
|
+
type = 'vulnerability';
|
|
6
|
+
detect(appPath) {
|
|
7
|
+
return (existsSync(join(appPath, 'package.json')) ||
|
|
8
|
+
existsSync(join(appPath, 'composer.json')) ||
|
|
9
|
+
existsSync(join(appPath, 'requirements.txt')));
|
|
10
|
+
}
|
|
11
|
+
async collect(app) {
|
|
12
|
+
const packages = this.extractPackages(app.composePath);
|
|
13
|
+
if (packages.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const findings = [];
|
|
16
|
+
const results = await Promise.allSettled(packages.map(pkg => this.queryOsv(app.name, pkg)));
|
|
17
|
+
for (const result of results) {
|
|
18
|
+
if (result.status === 'fulfilled') {
|
|
19
|
+
findings.push(...result.value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return findings;
|
|
23
|
+
}
|
|
24
|
+
extractPackages(appPath) {
|
|
25
|
+
const packages = [];
|
|
26
|
+
const pkgPath = join(appPath, 'package.json');
|
|
27
|
+
if (existsSync(pkgPath)) {
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
30
|
+
for (const [name, versionRaw] of Object.entries(pkg.dependencies ?? {})) {
|
|
31
|
+
const version = versionRaw.replace(/^[\^~>=<]/, '');
|
|
32
|
+
packages.push({ name, version, ecosystem: 'npm' });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* skip */ }
|
|
36
|
+
}
|
|
37
|
+
const composerPath = join(appPath, 'composer.json');
|
|
38
|
+
if (existsSync(composerPath)) {
|
|
39
|
+
try {
|
|
40
|
+
const composer = JSON.parse(readFileSync(composerPath, 'utf-8'));
|
|
41
|
+
for (const [name, versionRaw] of Object.entries(composer.require ?? {})) {
|
|
42
|
+
if (name.startsWith('php') || name.startsWith('ext-'))
|
|
43
|
+
continue;
|
|
44
|
+
const version = versionRaw.replace(/^[\^~>=<*]/, '');
|
|
45
|
+
packages.push({ name, version, ecosystem: 'Packagist' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch { /* skip */ }
|
|
49
|
+
}
|
|
50
|
+
const reqPath = join(appPath, 'requirements.txt');
|
|
51
|
+
if (existsSync(reqPath)) {
|
|
52
|
+
try {
|
|
53
|
+
const content = readFileSync(reqPath, 'utf-8');
|
|
54
|
+
for (const line of content.split('\n')) {
|
|
55
|
+
const match = line.trim().match(/^([a-zA-Z0-9_-]+)==(.+)/);
|
|
56
|
+
if (match)
|
|
57
|
+
packages.push({ name: match[1], version: match[2], ecosystem: 'PyPI' });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { /* skip */ }
|
|
61
|
+
}
|
|
62
|
+
return packages;
|
|
63
|
+
}
|
|
64
|
+
async queryOsv(appName, pkg) {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch('https://api.osv.dev/v1/query', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
version: pkg.version,
|
|
71
|
+
package: { name: pkg.name, ecosystem: pkg.ecosystem },
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
return [];
|
|
76
|
+
const data = await res.json();
|
|
77
|
+
if (!data.vulns?.length)
|
|
78
|
+
return [];
|
|
79
|
+
return data.vulns.map(vuln => {
|
|
80
|
+
const cvssEntry = vuln.severity?.find(s => s.type === 'CVSS_V3');
|
|
81
|
+
const cvss = cvssEntry ? parseFloat(cvssEntry.score) : 5.0;
|
|
82
|
+
const severity = severityFromCvss(cvss);
|
|
83
|
+
return {
|
|
84
|
+
appName,
|
|
85
|
+
source: 'vulnerability',
|
|
86
|
+
severity,
|
|
87
|
+
category: 'vulnerability',
|
|
88
|
+
title: `${pkg.name} ${pkg.version} — ${vuln.id}`,
|
|
89
|
+
detail: vuln.summary ?? `Vulnerability ${vuln.id} in ${pkg.name}@${pkg.version}`,
|
|
90
|
+
package: pkg.name,
|
|
91
|
+
currentVersion: pkg.version,
|
|
92
|
+
cveId: vuln.id,
|
|
93
|
+
fixable: true,
|
|
94
|
+
updatedAt: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DepsConfig } from './types.js';
|
|
2
|
+
export declare function defaultConfig(): DepsConfig;
|
|
3
|
+
export declare function mergeConfig(base: DepsConfig, overrides: Record<string, unknown>): DepsConfig;
|
|
4
|
+
export declare function loadConfig(path?: string): DepsConfig;
|
|
5
|
+
export declare function saveConfig(config: DepsConfig, path?: string): void;
|
|
6
|
+
export declare function configPath(): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const DEFAULT_CONFIG_PATH = join(__dirname, '..', '..', '..', 'data', 'deps-config.json');
|
|
6
|
+
export function defaultConfig() {
|
|
7
|
+
return {
|
|
8
|
+
scanIntervalHours: 6,
|
|
9
|
+
concurrency: 5,
|
|
10
|
+
notifications: {
|
|
11
|
+
telegram: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
chatId: '',
|
|
14
|
+
minSeverity: 'info',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
ignore: [],
|
|
18
|
+
severityOverrides: {
|
|
19
|
+
eolDaysWarning: 90,
|
|
20
|
+
majorVersionBehind: 'high',
|
|
21
|
+
minorVersionBehind: 'medium',
|
|
22
|
+
patchVersionBehind: 'low',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function mergeConfig(base, overrides) {
|
|
27
|
+
const result = structuredClone(base);
|
|
28
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
29
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value) && key in result) {
|
|
30
|
+
const baseVal = result[key];
|
|
31
|
+
if (baseVal !== null && typeof baseVal === 'object' && !Array.isArray(baseVal)) {
|
|
32
|
+
result[key] = mergeConfig(baseVal, value);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
result[key] = value;
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
export function loadConfig(path = DEFAULT_CONFIG_PATH) {
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return defaultConfig();
|
|
43
|
+
const raw = readFileSync(path, 'utf-8');
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
return mergeConfig(defaultConfig(), parsed);
|
|
46
|
+
}
|
|
47
|
+
export function saveConfig(config, path = DEFAULT_CONFIG_PATH) {
|
|
48
|
+
const dir = dirname(path);
|
|
49
|
+
if (!existsSync(dir))
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + '\n');
|
|
52
|
+
}
|
|
53
|
+
export function configPath() {
|
|
54
|
+
return DEFAULT_CONFIG_PATH;
|
|
55
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DepsCache, Finding, Severity } from '../types.js';
|
|
2
|
+
export declare function severityIcon(severity: Severity): string;
|
|
3
|
+
export declare function formatSummary(cache: DepsCache, appCount: number): string[];
|
|
4
|
+
export declare function formatAppDetail(appName: string, findings: Finding[]): string[];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { c, icon } from '../../../ui/output.js';
|
|
2
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
|
|
3
|
+
export function severityIcon(severity) {
|
|
4
|
+
switch (severity) {
|
|
5
|
+
case 'critical': return icon.err;
|
|
6
|
+
case 'high': return icon.warn;
|
|
7
|
+
case 'medium': return `${c.yellow}~${c.reset}`;
|
|
8
|
+
case 'low': return `${c.dim}.${c.reset}`;
|
|
9
|
+
case 'info': return icon.info;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function formatSummary(cache, appCount) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
const ago = formatAge(cache.lastScan);
|
|
15
|
+
if (cache.findings.length === 0) {
|
|
16
|
+
lines.push(`${icon.ok} All ${appCount} apps are up to date (scanned ${ago})`);
|
|
17
|
+
return lines;
|
|
18
|
+
}
|
|
19
|
+
const byApp = new Map();
|
|
20
|
+
for (const f of cache.findings) {
|
|
21
|
+
const arr = byApp.get(f.appName) ?? [];
|
|
22
|
+
arr.push(f);
|
|
23
|
+
byApp.set(f.appName, arr);
|
|
24
|
+
}
|
|
25
|
+
const rows = [];
|
|
26
|
+
for (const [app, findings] of byApp) {
|
|
27
|
+
rows.push({ name: app, findings, counts: countBySeverity(findings) });
|
|
28
|
+
}
|
|
29
|
+
rows.sort((a, b) => severityWeight(b.findings) - severityWeight(a.findings));
|
|
30
|
+
lines.push(`${c.dim}${appCount} apps, scanned ${ago}${c.reset}`);
|
|
31
|
+
lines.push('');
|
|
32
|
+
const header = ` ${'APP'.padEnd(24)} ${'SCORE'.padEnd(5)} ${'CRIT'.padEnd(4)} ${'HIGH'.padEnd(4)} ${'MED'.padEnd(4)} LOW`;
|
|
33
|
+
lines.push(`${c.bold}${header}${c.reset}`);
|
|
34
|
+
lines.push(` ${c.dim}${'-'.repeat(56)}${c.reset}`);
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
const score = healthScore(row.findings);
|
|
37
|
+
const crit = row.counts.critical > 0 ? `${c.red}${row.counts.critical}${c.reset}` : `${c.dim}0${c.reset}`;
|
|
38
|
+
const high = row.counts.high > 0 ? `${c.yellow}${row.counts.high}${c.reset}` : `${c.dim}0${c.reset}`;
|
|
39
|
+
lines.push(` ${c.bold}${row.name.padEnd(24)}${c.reset} ${score} ${padAnsi(crit, 4)} ${padAnsi(high, 4)} ${String(row.counts.medium).padEnd(4)} ${row.counts.low}`);
|
|
40
|
+
}
|
|
41
|
+
const critical = cache.findings.filter(f => f.severity === 'critical');
|
|
42
|
+
const high = cache.findings.filter(f => f.severity === 'high');
|
|
43
|
+
if (critical.length > 0) {
|
|
44
|
+
lines.push('');
|
|
45
|
+
lines.push(`${c.red}${c.bold}Critical (${critical.length})${c.reset}`);
|
|
46
|
+
for (const f of critical) {
|
|
47
|
+
lines.push(` ${icon.err} ${c.bold}${f.appName}${c.reset}: ${f.title}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (high.length > 0) {
|
|
51
|
+
lines.push('');
|
|
52
|
+
lines.push(`${c.yellow}${c.bold}High (${high.length})${c.reset}`);
|
|
53
|
+
for (const f of high) {
|
|
54
|
+
lines.push(` ${icon.warn} ${c.bold}${f.appName}${c.reset}: ${f.title}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
export function formatAppDetail(appName, findings) {
|
|
60
|
+
const lines = [];
|
|
61
|
+
for (const severity of SEVERITY_ORDER) {
|
|
62
|
+
const group = findings.filter(f => f.severity === severity);
|
|
63
|
+
if (group.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push(`${c.bold}${severity.toUpperCase()} (${group.length})${c.reset}`);
|
|
67
|
+
for (const f of group) {
|
|
68
|
+
lines.push(` ${severityIcon(f.severity)} ${f.title}`);
|
|
69
|
+
lines.push(` ${c.dim}${f.detail}${c.reset}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (findings.length === 0) {
|
|
73
|
+
lines.push(`${icon.ok} ${appName} is fully up to date`);
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
function healthScore(findings) {
|
|
78
|
+
const weights = findings.reduce((sum, f) => {
|
|
79
|
+
switch (f.severity) {
|
|
80
|
+
case 'critical': return sum + 4;
|
|
81
|
+
case 'high': return sum + 3;
|
|
82
|
+
case 'medium': return sum + 2;
|
|
83
|
+
case 'low': return sum + 1;
|
|
84
|
+
default: return sum;
|
|
85
|
+
}
|
|
86
|
+
}, 0);
|
|
87
|
+
const score = Math.max(0, 5 - Math.ceil(weights / 4));
|
|
88
|
+
const filled = `${c.green}${'#'.repeat(score)}${c.reset}`;
|
|
89
|
+
const empty = `${c.dim}${'_'.repeat(5 - score)}${c.reset}`;
|
|
90
|
+
return filled + empty;
|
|
91
|
+
}
|
|
92
|
+
function countBySeverity(findings) {
|
|
93
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
94
|
+
for (const f of findings)
|
|
95
|
+
counts[f.severity]++;
|
|
96
|
+
return counts;
|
|
97
|
+
}
|
|
98
|
+
function severityWeight(findings) {
|
|
99
|
+
return findings.reduce((sum, f) => {
|
|
100
|
+
const w = { critical: 1000, high: 100, medium: 10, low: 1, info: 0 };
|
|
101
|
+
return sum + w[f.severity];
|
|
102
|
+
}, 0);
|
|
103
|
+
}
|
|
104
|
+
function formatAge(isoDate) {
|
|
105
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
106
|
+
const mins = Math.floor(ms / 60_000);
|
|
107
|
+
if (mins < 1)
|
|
108
|
+
return 'just now';
|
|
109
|
+
if (mins < 60)
|
|
110
|
+
return `${mins}m ago`;
|
|
111
|
+
const hours = Math.floor(mins / 60);
|
|
112
|
+
if (hours < 24)
|
|
113
|
+
return `${hours}h ago`;
|
|
114
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
115
|
+
}
|
|
116
|
+
function stripAnsi(str) {
|
|
117
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
118
|
+
}
|
|
119
|
+
function padAnsi(str, width) {
|
|
120
|
+
const stripped = stripAnsi(str);
|
|
121
|
+
const pad = Math.max(0, width - stripped.length);
|
|
122
|
+
return str + ' '.repeat(pad);
|
|
123
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function formatMotd(cache, appCount) {
|
|
2
|
+
const lines = [];
|
|
3
|
+
const ago = formatAge(cache.lastScan);
|
|
4
|
+
lines.push('-- Fleet Deps ' + '-'.repeat(40));
|
|
5
|
+
if (cache.findings.length === 0) {
|
|
6
|
+
lines.push(` All ${appCount} apps up to date`);
|
|
7
|
+
lines.push(` Last scan: ${ago} | Run: fleet deps`);
|
|
8
|
+
return lines.join('\n');
|
|
9
|
+
}
|
|
10
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
11
|
+
for (const f of cache.findings)
|
|
12
|
+
counts[f.severity]++;
|
|
13
|
+
const parts = [];
|
|
14
|
+
if (counts.critical > 0)
|
|
15
|
+
parts.push(`${counts.critical} critical`);
|
|
16
|
+
if (counts.high > 0)
|
|
17
|
+
parts.push(`${counts.high} high`);
|
|
18
|
+
if (counts.medium > 0)
|
|
19
|
+
parts.push(`${counts.medium} medium`);
|
|
20
|
+
if (counts.low > 0)
|
|
21
|
+
parts.push(`${counts.low} low`);
|
|
22
|
+
lines.push(` ${parts.join(', ')} across ${appCount} apps`);
|
|
23
|
+
const urgent = cache.findings
|
|
24
|
+
.filter(f => f.severity === 'critical' || f.severity === 'high')
|
|
25
|
+
.slice(0, 5);
|
|
26
|
+
for (const f of urgent) {
|
|
27
|
+
const prefix = f.severity === 'critical' ? '!!' : ' !';
|
|
28
|
+
lines.push(` ${prefix} ${f.appName}: ${f.title}`);
|
|
29
|
+
}
|
|
30
|
+
const appsWithFindings = new Set(cache.findings.map(f => f.appName)).size;
|
|
31
|
+
const healthyCount = appCount - appsWithFindings;
|
|
32
|
+
if (healthyCount > 0) {
|
|
33
|
+
lines.push(` ${healthyCount} apps fully up to date`);
|
|
34
|
+
}
|
|
35
|
+
lines.push(` Last scan: ${ago} | Run: fleet deps`);
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
export function generateMotdScript(cachePath) {
|
|
39
|
+
return `#!/bin/bash
|
|
40
|
+
# fleet deps motd — auto-generated by fleet deps init
|
|
41
|
+
# shows dependency health summary on ssh login
|
|
42
|
+
|
|
43
|
+
CACHE="${cachePath}"
|
|
44
|
+
|
|
45
|
+
if [ ! -f "$CACHE" ]; then
|
|
46
|
+
echo "-- Fleet Deps: no scan data. Run: fleet deps scan"
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
/usr/local/bin/fleet deps --motd 2>/dev/null || echo "-- Fleet Deps: run fleet deps scan"
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
function formatAge(isoDate) {
|
|
54
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
55
|
+
const mins = Math.floor(ms / 60_000);
|
|
56
|
+
if (mins < 1)
|
|
57
|
+
return 'just now';
|
|
58
|
+
if (mins < 60)
|
|
59
|
+
return `${mins}m ago`;
|
|
60
|
+
const hours = Math.floor(mins / 60);
|
|
61
|
+
if (hours < 24)
|
|
62
|
+
return `${hours}h ago`;
|
|
63
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Finding, Severity } from '../types.js';
|
|
2
|
+
export declare function formatTelegramMessage(findings: Finding[], appCount: number): string;
|
|
3
|
+
export declare function findNewFindings(current: Finding[], previous: Finding[]): Finding[];
|
|
4
|
+
export declare function sendTelegramNotification(findings: Finding[], appCount: number, previousFindings: Finding[], minSeverity: Severity): Promise<boolean>;
|
|
5
|
+
export declare function loadNotifiedFindings(): Finding[];
|
|
6
|
+
export declare function saveNotifiedFindings(findings: Finding[]): void;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const NOTIFIED_PATH = join(__dirname, '..', '..', '..', '..', 'data', 'notified-findings.json');
|
|
6
|
+
const TELEGRAM_CONFIG_PATH = '/etc/fleet/telegram.json';
|
|
7
|
+
export function formatTelegramMessage(findings, appCount) {
|
|
8
|
+
if (findings.length === 0)
|
|
9
|
+
return '';
|
|
10
|
+
const lines = [];
|
|
11
|
+
const date = new Date().toISOString().split('T')[0];
|
|
12
|
+
lines.push(`<b>Fleet Deps Scan — ${date}</b>\n`);
|
|
13
|
+
const groups = {
|
|
14
|
+
critical: [], high: [], medium: [], low: [], info: [],
|
|
15
|
+
};
|
|
16
|
+
for (const f of findings)
|
|
17
|
+
groups[f.severity].push(f);
|
|
18
|
+
for (const severity of ['critical', 'high', 'medium', 'low']) {
|
|
19
|
+
const group = groups[severity];
|
|
20
|
+
if (group.length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
lines.push(`<b>${severity.charAt(0).toUpperCase() + severity.slice(1)} (${group.length}):</b>`);
|
|
23
|
+
for (const f of group.slice(0, 10)) {
|
|
24
|
+
lines.push(`• ${f.appName}: ${escapeHtml(f.title)}`);
|
|
25
|
+
}
|
|
26
|
+
if (group.length > 10) {
|
|
27
|
+
lines.push(` <i>...and ${group.length - 10} more</i>`);
|
|
28
|
+
}
|
|
29
|
+
lines.push('');
|
|
30
|
+
}
|
|
31
|
+
const totalApps = new Set(findings.map(f => f.appName)).size;
|
|
32
|
+
lines.push(`${totalApps} apps affected out of ${appCount}`);
|
|
33
|
+
return lines.join('\n');
|
|
34
|
+
}
|
|
35
|
+
export function findNewFindings(current, previous) {
|
|
36
|
+
const severityOrder = ['info', 'low', 'medium', 'high', 'critical'];
|
|
37
|
+
return current.filter(f => {
|
|
38
|
+
const prev = previous.find(p => p.appName === f.appName && p.title === f.title);
|
|
39
|
+
if (!prev)
|
|
40
|
+
return true;
|
|
41
|
+
return severityOrder.indexOf(f.severity) > severityOrder.indexOf(prev.severity);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export async function sendTelegramNotification(findings, appCount, previousFindings, minSeverity) {
|
|
45
|
+
const config = loadTelegramConfig();
|
|
46
|
+
if (!config)
|
|
47
|
+
return false;
|
|
48
|
+
const newFindings = findNewFindings(findings, previousFindings);
|
|
49
|
+
if (newFindings.length === 0)
|
|
50
|
+
return false;
|
|
51
|
+
const severityOrder = ['info', 'low', 'medium', 'high', 'critical'];
|
|
52
|
+
const minIdx = severityOrder.indexOf(minSeverity);
|
|
53
|
+
const filtered = newFindings.filter(f => severityOrder.indexOf(f.severity) >= minIdx);
|
|
54
|
+
if (filtered.length === 0)
|
|
55
|
+
return false;
|
|
56
|
+
const message = formatTelegramMessage(filtered, appCount);
|
|
57
|
+
if (!message)
|
|
58
|
+
return false;
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
chat_id: config.chatId,
|
|
65
|
+
text: message,
|
|
66
|
+
parse_mode: 'HTML',
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
return res.ok;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function loadNotifiedFindings() {
|
|
76
|
+
if (!existsSync(NOTIFIED_PATH))
|
|
77
|
+
return [];
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(NOTIFIED_PATH, 'utf-8'));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function saveNotifiedFindings(findings) {
|
|
86
|
+
const dir = dirname(NOTIFIED_PATH);
|
|
87
|
+
if (!existsSync(dir))
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
writeFileSync(NOTIFIED_PATH, JSON.stringify(findings, null, 2) + '\n');
|
|
90
|
+
}
|
|
91
|
+
function loadTelegramConfig() {
|
|
92
|
+
if (!existsSync(TELEGRAM_CONFIG_PATH))
|
|
93
|
+
return null;
|
|
94
|
+
try {
|
|
95
|
+
const raw = JSON.parse(readFileSync(TELEGRAM_CONFIG_PATH, 'utf-8'));
|
|
96
|
+
if (!raw.botToken || !raw.chatId)
|
|
97
|
+
return null;
|
|
98
|
+
return { botToken: raw.botToken, chatId: String(raw.chatId) };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function escapeHtml(text) {
|
|
105
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
106
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AppEntry } from '../registry.js';
|
|
2
|
+
import type { Collector, DepsCache, DepsConfig } from './types.js';
|
|
3
|
+
export declare function createCollectors(config: DepsConfig): Collector[];
|
|
4
|
+
export declare function runScan(apps: AppEntry[], config: DepsConfig, collectors?: Collector[]): Promise<DepsCache>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { NpmCollector } from './collectors/npm.js';
|
|
2
|
+
import { ComposerCollector } from './collectors/composer.js';
|
|
3
|
+
import { PipCollector } from './collectors/pip.js';
|
|
4
|
+
import { DockerImageCollector } from './collectors/docker-image.js';
|
|
5
|
+
import { DockerRunningCollector } from './collectors/docker-running.js';
|
|
6
|
+
import { EolCollector } from './collectors/eol.js';
|
|
7
|
+
import { VulnerabilityCollector } from './collectors/vulnerability.js';
|
|
8
|
+
import { GitHubPrCollector } from './collectors/github-pr.js';
|
|
9
|
+
export function createCollectors(config) {
|
|
10
|
+
return [
|
|
11
|
+
new NpmCollector(config.severityOverrides),
|
|
12
|
+
new ComposerCollector(config.severityOverrides),
|
|
13
|
+
new PipCollector(config.severityOverrides),
|
|
14
|
+
new DockerImageCollector(config.severityOverrides),
|
|
15
|
+
new DockerRunningCollector(config.severityOverrides),
|
|
16
|
+
new EolCollector(config.severityOverrides.eolDaysWarning),
|
|
17
|
+
new VulnerabilityCollector(),
|
|
18
|
+
new GitHubPrCollector(),
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
export async function runScan(apps, config, collectors) {
|
|
22
|
+
const start = Date.now();
|
|
23
|
+
const allCollectors = collectors ?? createCollectors(config);
|
|
24
|
+
const findings = [];
|
|
25
|
+
const errors = [];
|
|
26
|
+
// build work items: [app, collector] pairs where collector.detect passes
|
|
27
|
+
const work = [];
|
|
28
|
+
for (const app of apps) {
|
|
29
|
+
for (const collector of allCollectors) {
|
|
30
|
+
if (collector.detect(app.composePath)) {
|
|
31
|
+
work.push({ app, collector });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// run with concurrency limit
|
|
36
|
+
const concurrency = config.concurrency;
|
|
37
|
+
for (let i = 0; i < work.length; i += concurrency) {
|
|
38
|
+
const batch = work.slice(i, i + concurrency);
|
|
39
|
+
const results = await Promise.allSettled(batch.map(async ({ app, collector }) => {
|
|
40
|
+
try {
|
|
41
|
+
return await collector.collect(app);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
errors.push({
|
|
45
|
+
collector: collector.type,
|
|
46
|
+
appName: app.name,
|
|
47
|
+
message: err instanceof Error ? err.message : String(err),
|
|
48
|
+
timestamp: new Date().toISOString(),
|
|
49
|
+
});
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
for (const result of results) {
|
|
54
|
+
if (result.status === 'fulfilled') {
|
|
55
|
+
findings.push(...result.value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const filtered = applyIgnoreRules(findings, config.ignore);
|
|
60
|
+
return {
|
|
61
|
+
version: 1,
|
|
62
|
+
lastScan: new Date().toISOString(),
|
|
63
|
+
scanDurationMs: Date.now() - start,
|
|
64
|
+
findings: filtered,
|
|
65
|
+
errors,
|
|
66
|
+
config,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function applyIgnoreRules(findings, rules) {
|
|
70
|
+
if (rules.length === 0)
|
|
71
|
+
return findings;
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const activeRules = rules.filter(r => {
|
|
74
|
+
if (r.until)
|
|
75
|
+
return new Date(r.until).getTime() > now;
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
return findings.filter(f => {
|
|
79
|
+
return !activeRules.some(rule => {
|
|
80
|
+
if (rule.appName && rule.appName !== f.appName)
|
|
81
|
+
return false;
|
|
82
|
+
if (rule.package && rule.package !== f.package)
|
|
83
|
+
return false;
|
|
84
|
+
if (rule.source && rule.source !== f.source)
|
|
85
|
+
return false;
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Severity, DepsConfig } from './types.js';
|
|
2
|
+
type SeverityOverrides = DepsConfig['severityOverrides'];
|
|
3
|
+
export declare function severityFromVersionDelta(current: string, latest: string, overrides: SeverityOverrides): Severity;
|
|
4
|
+
export declare function severityFromEol(eolDate: string, warningDays: number): Severity;
|
|
5
|
+
export declare function severityFromCvss(score: number): Severity;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function severityFromVersionDelta(current, latest, overrides) {
|
|
2
|
+
const cur = parseSemver(current);
|
|
3
|
+
const lat = parseSemver(latest);
|
|
4
|
+
if (!cur || !lat)
|
|
5
|
+
return 'medium';
|
|
6
|
+
if (cur.major < lat.major)
|
|
7
|
+
return overrides.majorVersionBehind;
|
|
8
|
+
if (cur.minor < lat.minor)
|
|
9
|
+
return overrides.minorVersionBehind;
|
|
10
|
+
if (cur.patch < lat.patch)
|
|
11
|
+
return overrides.patchVersionBehind;
|
|
12
|
+
return 'info';
|
|
13
|
+
}
|
|
14
|
+
export function severityFromEol(eolDate, warningDays) {
|
|
15
|
+
const eol = new Date(eolDate).getTime();
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
const daysUntil = (eol - now) / (24 * 60 * 60 * 1000);
|
|
18
|
+
if (daysUntil <= 0)
|
|
19
|
+
return 'critical';
|
|
20
|
+
if (daysUntil <= 30)
|
|
21
|
+
return 'high';
|
|
22
|
+
if (daysUntil <= warningDays)
|
|
23
|
+
return 'medium';
|
|
24
|
+
return 'info';
|
|
25
|
+
}
|
|
26
|
+
export function severityFromCvss(score) {
|
|
27
|
+
if (score >= 9)
|
|
28
|
+
return 'critical';
|
|
29
|
+
if (score >= 7)
|
|
30
|
+
return 'high';
|
|
31
|
+
if (score >= 4)
|
|
32
|
+
return 'medium';
|
|
33
|
+
return 'low';
|
|
34
|
+
}
|
|
35
|
+
function parseSemver(version) {
|
|
36
|
+
const clean = version.replace(/^v/, '');
|
|
37
|
+
const match = clean.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
38
|
+
if (!match)
|
|
39
|
+
return null;
|
|
40
|
+
return {
|
|
41
|
+
major: parseInt(match[1], 10),
|
|
42
|
+
minor: parseInt(match[2], 10),
|
|
43
|
+
patch: parseInt(match[3], 10),
|
|
44
|
+
};
|
|
45
|
+
}
|