@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,132 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { severityFromVersionDelta } from '../severity.js';
|
|
4
|
+
export class DockerImageCollector {
|
|
5
|
+
overrides;
|
|
6
|
+
type = 'docker-image';
|
|
7
|
+
constructor(overrides) {
|
|
8
|
+
this.overrides = overrides;
|
|
9
|
+
}
|
|
10
|
+
detect(appPath) {
|
|
11
|
+
return (existsSync(join(appPath, 'Dockerfile')) ||
|
|
12
|
+
existsSync(join(appPath, 'docker-compose.yml')) ||
|
|
13
|
+
existsSync(join(appPath, 'docker-compose.yaml')));
|
|
14
|
+
}
|
|
15
|
+
async collect(app) {
|
|
16
|
+
const images = new Map();
|
|
17
|
+
const dockerfilePath = join(app.composePath, 'Dockerfile');
|
|
18
|
+
if (existsSync(dockerfilePath)) {
|
|
19
|
+
for (const img of this.parseDockerfile(readFileSync(dockerfilePath, 'utf-8'))) {
|
|
20
|
+
images.set(`${img.image}:${img.tag}`, img);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const composeFile = app.composeFile ?? 'docker-compose.yml';
|
|
24
|
+
const composePath = join(app.composePath, composeFile);
|
|
25
|
+
if (existsSync(composePath)) {
|
|
26
|
+
for (const img of this.parseComposeImages(readFileSync(composePath, 'utf-8'))) {
|
|
27
|
+
images.set(`${img.image}:${img.tag}`, img);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const composeYaml = join(app.composePath, 'docker-compose.yaml');
|
|
31
|
+
if (!existsSync(composePath) && existsSync(composeYaml)) {
|
|
32
|
+
for (const img of this.parseComposeImages(readFileSync(composeYaml, 'utf-8'))) {
|
|
33
|
+
images.set(`${img.image}:${img.tag}`, img);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const findings = [];
|
|
37
|
+
const results = await Promise.allSettled(Array.from(images.values()).map(img => this.checkImage(app.name, img)));
|
|
38
|
+
for (const result of results) {
|
|
39
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
40
|
+
findings.push(result.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return findings;
|
|
44
|
+
}
|
|
45
|
+
parseDockerfile(content) {
|
|
46
|
+
const images = [];
|
|
47
|
+
for (const line of content.split('\n')) {
|
|
48
|
+
const match = line.match(/^FROM\s+(\S+?)(?::(\S+?))?(?:\s+AS\s+\S+)?$/i);
|
|
49
|
+
if (match) {
|
|
50
|
+
images.push({ image: match[1], tag: match[2] ?? 'latest' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return images;
|
|
54
|
+
}
|
|
55
|
+
parseComposeImages(content) {
|
|
56
|
+
const images = [];
|
|
57
|
+
for (const line of content.split('\n')) {
|
|
58
|
+
const match = line.match(/^\s+image:\s*['"]?(\S+?)(?::(\S+?))?['"]?\s*$/);
|
|
59
|
+
if (match) {
|
|
60
|
+
images.push({ image: match[1], tag: match[2] ?? 'latest' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return images;
|
|
64
|
+
}
|
|
65
|
+
async checkImage(appName, img) {
|
|
66
|
+
const tagVersion = img.tag.match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
67
|
+
if (!tagVersion)
|
|
68
|
+
return null;
|
|
69
|
+
// only check docker hub library images for now
|
|
70
|
+
const isLibrary = !img.image.includes('/') || img.image.startsWith('library/');
|
|
71
|
+
if (img.image.includes('.') && !img.image.startsWith('docker.io'))
|
|
72
|
+
return null;
|
|
73
|
+
const namespace = isLibrary ? 'library' : img.image.split('/').slice(0, -1).join('/');
|
|
74
|
+
const repo = isLibrary ? img.image.replace('library/', '') : img.image.split('/').pop();
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(`https://hub.docker.com/v2/repositories/${namespace}/${repo}/tags?page_size=50&ordering=last_updated`);
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
return null;
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
const suffix = img.tag.replace(/^v?\d+(?:\.\d+)*/, '');
|
|
81
|
+
const semverTags = data.results
|
|
82
|
+
.map(t => t.name)
|
|
83
|
+
.filter(name => {
|
|
84
|
+
if (suffix && !name.endsWith(suffix))
|
|
85
|
+
return false;
|
|
86
|
+
return /^v?\d+\.\d+/.test(name);
|
|
87
|
+
})
|
|
88
|
+
.sort((a, b) => {
|
|
89
|
+
const av = a.replace(/^v/, '').replace(suffix, '');
|
|
90
|
+
const bv = b.replace(/^v/, '').replace(suffix, '');
|
|
91
|
+
return compareVersions(bv, av);
|
|
92
|
+
});
|
|
93
|
+
if (semverTags.length === 0)
|
|
94
|
+
return null;
|
|
95
|
+
const latestTag = semverTags[0];
|
|
96
|
+
const currentClean = img.tag.replace(suffix, '').replace(/^v/, '');
|
|
97
|
+
const latestClean = latestTag.replace(suffix, '').replace(/^v/, '');
|
|
98
|
+
if (currentClean === latestClean)
|
|
99
|
+
return null;
|
|
100
|
+
const severity = severityFromVersionDelta(currentClean, latestClean, this.overrides);
|
|
101
|
+
if (severity === 'info')
|
|
102
|
+
return null;
|
|
103
|
+
return {
|
|
104
|
+
appName,
|
|
105
|
+
source: 'docker-image',
|
|
106
|
+
severity,
|
|
107
|
+
category: 'image-update',
|
|
108
|
+
title: `${img.image}:${img.tag} -> ${latestTag}`,
|
|
109
|
+
detail: `Docker image ${img.image} has newer tag ${latestTag} available`,
|
|
110
|
+
package: img.image,
|
|
111
|
+
currentVersion: img.tag,
|
|
112
|
+
latestVersion: latestTag,
|
|
113
|
+
fixable: true,
|
|
114
|
+
updatedAt: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function compareVersions(a, b) {
|
|
123
|
+
const pa = a.split('.').map(Number);
|
|
124
|
+
const pb = b.split('.').map(Number);
|
|
125
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
126
|
+
const va = pa[i] ?? 0;
|
|
127
|
+
const vb = pb[i] ?? 0;
|
|
128
|
+
if (va !== vb)
|
|
129
|
+
return va - vb;
|
|
130
|
+
}
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AppEntry } from '../../registry.js';
|
|
2
|
+
import type { Collector, Finding, DepsConfig } from '../types.js';
|
|
3
|
+
type SeverityOverrides = DepsConfig['severityOverrides'];
|
|
4
|
+
interface InspectResult {
|
|
5
|
+
image: string;
|
|
6
|
+
tag: string;
|
|
7
|
+
digest: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class DockerRunningCollector implements Collector {
|
|
10
|
+
private _overrides;
|
|
11
|
+
type: "docker-running";
|
|
12
|
+
constructor(_overrides: SeverityOverrides);
|
|
13
|
+
detect(_appPath: string, app?: AppEntry): boolean;
|
|
14
|
+
collect(app: AppEntry): Promise<Finding[]>;
|
|
15
|
+
parseInspectOutput(json: string): InspectResult | null;
|
|
16
|
+
}
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { exec } from '../../exec.js';
|
|
2
|
+
export class DockerRunningCollector {
|
|
3
|
+
_overrides;
|
|
4
|
+
type = 'docker-running';
|
|
5
|
+
constructor(_overrides) {
|
|
6
|
+
this._overrides = _overrides;
|
|
7
|
+
}
|
|
8
|
+
detect(_appPath, app) {
|
|
9
|
+
return (app?.containers?.length ?? 0) > 0;
|
|
10
|
+
}
|
|
11
|
+
async collect(app) {
|
|
12
|
+
const findings = [];
|
|
13
|
+
for (const container of app.containers) {
|
|
14
|
+
const result = exec(`docker inspect ${container}`, { timeout: 10_000 });
|
|
15
|
+
if (!result.ok)
|
|
16
|
+
continue;
|
|
17
|
+
const info = this.parseInspectOutput(result.stdout);
|
|
18
|
+
if (!info)
|
|
19
|
+
continue;
|
|
20
|
+
// check if running image differs from what compose/dockerfile specifies
|
|
21
|
+
// this is drift detection — the container is running but may be stale
|
|
22
|
+
const tagVersion = info.tag.match(/^v?(\d+)(?:\.(\d+))?/);
|
|
23
|
+
if (!tagVersion)
|
|
24
|
+
continue;
|
|
25
|
+
findings.push({
|
|
26
|
+
appName: app.name,
|
|
27
|
+
source: 'docker-running',
|
|
28
|
+
severity: 'info',
|
|
29
|
+
category: 'image-update',
|
|
30
|
+
title: `${container} running ${info.image}:${info.tag}`,
|
|
31
|
+
detail: `Container ${container} is running image ${info.image}:${info.tag} (digest: ${info.digest.slice(0, 19)})`,
|
|
32
|
+
package: info.image,
|
|
33
|
+
currentVersion: info.tag,
|
|
34
|
+
fixable: false,
|
|
35
|
+
updatedAt: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
}
|
|
40
|
+
parseInspectOutput(json) {
|
|
41
|
+
try {
|
|
42
|
+
const data = JSON.parse(json);
|
|
43
|
+
if (!data[0])
|
|
44
|
+
return null;
|
|
45
|
+
const imageStr = data[0].Config.Image;
|
|
46
|
+
const parts = imageStr.split(':');
|
|
47
|
+
const tag = parts.length > 1 ? parts.pop() : 'latest';
|
|
48
|
+
const image = parts.join(':');
|
|
49
|
+
return { image, tag, digest: data[0].Image };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AppEntry } from '../../registry.js';
|
|
2
|
+
import type { Collector, Finding } from '../types.js';
|
|
3
|
+
interface RuntimeRef {
|
|
4
|
+
product: string;
|
|
5
|
+
version: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class EolCollector implements Collector {
|
|
8
|
+
private warningDays;
|
|
9
|
+
type: "eol";
|
|
10
|
+
constructor(warningDays: number);
|
|
11
|
+
detect(appPath: string): boolean;
|
|
12
|
+
collect(app: AppEntry): Promise<Finding[]>;
|
|
13
|
+
detectRuntimes(appPath: string): RuntimeRef[];
|
|
14
|
+
private checkEol;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { severityFromEol } from '../severity.js';
|
|
4
|
+
export class EolCollector {
|
|
5
|
+
warningDays;
|
|
6
|
+
type = 'eol';
|
|
7
|
+
constructor(warningDays) {
|
|
8
|
+
this.warningDays = warningDays;
|
|
9
|
+
}
|
|
10
|
+
detect(appPath) {
|
|
11
|
+
return this.detectRuntimes(appPath).length > 0;
|
|
12
|
+
}
|
|
13
|
+
async collect(app) {
|
|
14
|
+
const runtimes = this.detectRuntimes(app.composePath);
|
|
15
|
+
const findings = [];
|
|
16
|
+
const results = await Promise.allSettled(runtimes.map(rt => this.checkEol(app.name, rt)));
|
|
17
|
+
for (const result of results) {
|
|
18
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
19
|
+
findings.push(result.value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return findings;
|
|
23
|
+
}
|
|
24
|
+
detectRuntimes(appPath) {
|
|
25
|
+
const runtimes = [];
|
|
26
|
+
// node from package.json engines
|
|
27
|
+
const pkgPath = join(appPath, 'package.json');
|
|
28
|
+
if (existsSync(pkgPath)) {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
31
|
+
const nodeEngine = pkg.engines?.node;
|
|
32
|
+
if (nodeEngine) {
|
|
33
|
+
const ver = nodeEngine.match(/(\d+)/);
|
|
34
|
+
if (ver)
|
|
35
|
+
runtimes.push({ product: 'node', version: ver[1] });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { /* skip */ }
|
|
39
|
+
}
|
|
40
|
+
// node from .nvmrc
|
|
41
|
+
const nvmrcPath = join(appPath, '.nvmrc');
|
|
42
|
+
if (existsSync(nvmrcPath)) {
|
|
43
|
+
const ver = readFileSync(nvmrcPath, 'utf-8').trim().match(/(\d+)/);
|
|
44
|
+
if (ver && !runtimes.some(r => r.product === 'node')) {
|
|
45
|
+
runtimes.push({ product: 'node', version: ver[1] });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// php from composer.json
|
|
49
|
+
const composerPath = join(appPath, 'composer.json');
|
|
50
|
+
if (existsSync(composerPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const composer = JSON.parse(readFileSync(composerPath, 'utf-8'));
|
|
53
|
+
const phpReq = composer.require?.php;
|
|
54
|
+
if (phpReq) {
|
|
55
|
+
const ver = phpReq.match(/(\d+\.\d+)/);
|
|
56
|
+
if (ver)
|
|
57
|
+
runtimes.push({ product: 'php', version: ver[1] });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { /* skip */ }
|
|
61
|
+
}
|
|
62
|
+
// python from pyproject.toml
|
|
63
|
+
const pyprojectPath = join(appPath, 'pyproject.toml');
|
|
64
|
+
if (existsSync(pyprojectPath)) {
|
|
65
|
+
try {
|
|
66
|
+
const content = readFileSync(pyprojectPath, 'utf-8');
|
|
67
|
+
const ver = content.match(/requires-python\s*=\s*">=?(\d+\.\d+)"/);
|
|
68
|
+
if (ver)
|
|
69
|
+
runtimes.push({ product: 'python', version: ver[1] });
|
|
70
|
+
}
|
|
71
|
+
catch { /* skip */ }
|
|
72
|
+
}
|
|
73
|
+
// runtimes from dockerfile FROM lines
|
|
74
|
+
const dockerfilePath = join(appPath, 'Dockerfile');
|
|
75
|
+
if (existsSync(dockerfilePath)) {
|
|
76
|
+
try {
|
|
77
|
+
const content = readFileSync(dockerfilePath, 'utf-8');
|
|
78
|
+
for (const line of content.split('\n')) {
|
|
79
|
+
const match = line.match(/^FROM\s+(node|php|python):(\d+(?:\.\d+)?)/i);
|
|
80
|
+
if (match) {
|
|
81
|
+
const product = match[1].toLowerCase();
|
|
82
|
+
if (!runtimes.some(r => r.product === product)) {
|
|
83
|
+
runtimes.push({ product, version: match[2] });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch { /* skip */ }
|
|
89
|
+
}
|
|
90
|
+
return runtimes;
|
|
91
|
+
}
|
|
92
|
+
async checkEol(appName, rt) {
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch(`https://endoflife.date/api/${rt.product}/${rt.version}.json`);
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
return null;
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
if (typeof data.eol === 'boolean') {
|
|
99
|
+
if (data.eol) {
|
|
100
|
+
return {
|
|
101
|
+
appName,
|
|
102
|
+
source: 'eol',
|
|
103
|
+
severity: 'critical',
|
|
104
|
+
category: 'eol-warning',
|
|
105
|
+
title: `${rt.product} ${rt.version} is end-of-life`,
|
|
106
|
+
detail: `${rt.product} ${rt.version} has reached end of life and no longer receives updates`,
|
|
107
|
+
package: rt.product,
|
|
108
|
+
currentVersion: rt.version,
|
|
109
|
+
fixable: false,
|
|
110
|
+
updatedAt: new Date().toISOString(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const severity = severityFromEol(data.eol, this.warningDays);
|
|
116
|
+
if (severity === 'info')
|
|
117
|
+
return null;
|
|
118
|
+
const daysUntil = Math.ceil((new Date(data.eol).getTime() - Date.now()) / (24 * 60 * 60 * 1000));
|
|
119
|
+
return {
|
|
120
|
+
appName,
|
|
121
|
+
source: 'eol',
|
|
122
|
+
severity,
|
|
123
|
+
category: 'eol-warning',
|
|
124
|
+
title: daysUntil <= 0
|
|
125
|
+
? `${rt.product} ${rt.version} is end-of-life`
|
|
126
|
+
: `${rt.product} ${rt.version} EOL in ${daysUntil} days`,
|
|
127
|
+
detail: `${rt.product} ${rt.version} reaches end of life on ${data.eol}`,
|
|
128
|
+
eolDate: data.eol,
|
|
129
|
+
package: rt.product,
|
|
130
|
+
currentVersion: rt.version,
|
|
131
|
+
fixable: false,
|
|
132
|
+
updatedAt: new Date().toISOString(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppEntry } from '../../registry.js';
|
|
2
|
+
import type { Collector, Finding } from '../types.js';
|
|
3
|
+
export declare class GitHubPrCollector implements Collector {
|
|
4
|
+
type: "github-pr";
|
|
5
|
+
detect(_appPath: string, app?: AppEntry): boolean;
|
|
6
|
+
collect(app: AppEntry): Promise<Finding[]>;
|
|
7
|
+
private isDependencyPr;
|
|
8
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { exec } from '../../exec.js';
|
|
2
|
+
export class GitHubPrCollector {
|
|
3
|
+
type = 'github-pr';
|
|
4
|
+
detect(_appPath, app) {
|
|
5
|
+
return !!app?.gitRepo;
|
|
6
|
+
}
|
|
7
|
+
async collect(app) {
|
|
8
|
+
if (!app.gitRepo)
|
|
9
|
+
return [];
|
|
10
|
+
const result = exec(`gh pr list --repo ${app.gitRepo} --state open --json number,title,url,labels --limit 50`, { timeout: 15_000 });
|
|
11
|
+
if (!result.ok)
|
|
12
|
+
return [];
|
|
13
|
+
try {
|
|
14
|
+
const prs = JSON.parse(result.stdout);
|
|
15
|
+
return prs
|
|
16
|
+
.filter(pr => this.isDependencyPr(pr))
|
|
17
|
+
.map(pr => ({
|
|
18
|
+
appName: app.name,
|
|
19
|
+
source: 'github-pr',
|
|
20
|
+
severity: 'info',
|
|
21
|
+
category: 'pending-pr',
|
|
22
|
+
title: `PR #${pr.number}: ${pr.title}`,
|
|
23
|
+
detail: `Open dependency PR: ${pr.url}`,
|
|
24
|
+
prUrl: pr.url,
|
|
25
|
+
fixable: false,
|
|
26
|
+
updatedAt: new Date().toISOString(),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
isDependencyPr(pr) {
|
|
34
|
+
const depLabels = ['dependencies', 'deps', 'renovate', 'dependabot'];
|
|
35
|
+
if (pr.labels.some(l => depLabels.includes(l.name.toLowerCase())))
|
|
36
|
+
return true;
|
|
37
|
+
const depPrefixes = ['chore(deps)', 'fix(deps)', 'deps/', 'build(deps)'];
|
|
38
|
+
return depPrefixes.some(prefix => pr.title.toLowerCase().startsWith(prefix));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AppEntry } from '../../registry.js';
|
|
2
|
+
import type { Collector, Finding, DepsConfig } from '../types.js';
|
|
3
|
+
type SeverityOverrides = DepsConfig['severityOverrides'];
|
|
4
|
+
export declare class NpmCollector implements Collector {
|
|
5
|
+
private overrides;
|
|
6
|
+
type: "npm";
|
|
7
|
+
constructor(overrides: SeverityOverrides);
|
|
8
|
+
detect(appPath: string): boolean;
|
|
9
|
+
collect(app: AppEntry): Promise<Finding[]>;
|
|
10
|
+
private checkPackage;
|
|
11
|
+
}
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { severityFromVersionDelta } from '../severity.js';
|
|
4
|
+
export class NpmCollector {
|
|
5
|
+
overrides;
|
|
6
|
+
type = 'npm';
|
|
7
|
+
constructor(overrides) {
|
|
8
|
+
this.overrides = overrides;
|
|
9
|
+
}
|
|
10
|
+
detect(appPath) {
|
|
11
|
+
return existsSync(join(appPath, 'package.json'));
|
|
12
|
+
}
|
|
13
|
+
async collect(app) {
|
|
14
|
+
const pkgPath = join(app.composePath, 'package.json');
|
|
15
|
+
if (!existsSync(pkgPath))
|
|
16
|
+
return [];
|
|
17
|
+
const raw = readFileSync(pkgPath, 'utf-8');
|
|
18
|
+
const pkg = JSON.parse(raw);
|
|
19
|
+
const allDeps = {
|
|
20
|
+
...pkg.dependencies,
|
|
21
|
+
...pkg.devDependencies,
|
|
22
|
+
};
|
|
23
|
+
const findings = [];
|
|
24
|
+
const results = await Promise.allSettled(Object.entries(allDeps).map(([name, version]) => this.checkPackage(app.name, name, version)));
|
|
25
|
+
for (const result of results) {
|
|
26
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
27
|
+
findings.push(result.value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return findings;
|
|
31
|
+
}
|
|
32
|
+
async checkPackage(appName, name, currentRaw) {
|
|
33
|
+
const current = currentRaw.replace(/^[\^~>=<]/, '');
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`);
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
return null;
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
const latest = data.version;
|
|
40
|
+
if (current === latest)
|
|
41
|
+
return null;
|
|
42
|
+
const severity = severityFromVersionDelta(current, latest, this.overrides);
|
|
43
|
+
if (severity === 'info')
|
|
44
|
+
return null;
|
|
45
|
+
return {
|
|
46
|
+
appName,
|
|
47
|
+
source: 'npm',
|
|
48
|
+
severity,
|
|
49
|
+
category: 'outdated-dep',
|
|
50
|
+
title: `${name} ${current} -> ${latest}`,
|
|
51
|
+
detail: `npm package ${name} can be updated from ${current} to ${latest}`,
|
|
52
|
+
package: name,
|
|
53
|
+
currentVersion: current,
|
|
54
|
+
latestVersion: latest,
|
|
55
|
+
fixable: true,
|
|
56
|
+
updatedAt: new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AppEntry } from '../../registry.js';
|
|
2
|
+
import type { Collector, Finding, DepsConfig } from '../types.js';
|
|
3
|
+
type SeverityOverrides = DepsConfig['severityOverrides'];
|
|
4
|
+
export declare class PipCollector implements Collector {
|
|
5
|
+
private overrides;
|
|
6
|
+
type: "pip";
|
|
7
|
+
constructor(overrides: SeverityOverrides);
|
|
8
|
+
detect(appPath: string): boolean;
|
|
9
|
+
collect(app: AppEntry): Promise<Finding[]>;
|
|
10
|
+
private parseDeps;
|
|
11
|
+
private parseRequirementsTxt;
|
|
12
|
+
private parsePyprojectToml;
|
|
13
|
+
private checkPackage;
|
|
14
|
+
}
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { severityFromVersionDelta } from '../severity.js';
|
|
4
|
+
export class PipCollector {
|
|
5
|
+
overrides;
|
|
6
|
+
type = 'pip';
|
|
7
|
+
constructor(overrides) {
|
|
8
|
+
this.overrides = overrides;
|
|
9
|
+
}
|
|
10
|
+
detect(appPath) {
|
|
11
|
+
return (existsSync(join(appPath, 'requirements.txt')) ||
|
|
12
|
+
existsSync(join(appPath, 'pyproject.toml')));
|
|
13
|
+
}
|
|
14
|
+
async collect(app) {
|
|
15
|
+
const deps = this.parseDeps(app.composePath);
|
|
16
|
+
if (deps.length === 0)
|
|
17
|
+
return [];
|
|
18
|
+
const findings = [];
|
|
19
|
+
const results = await Promise.allSettled(deps.map(([name, version]) => this.checkPackage(app.name, name, version)));
|
|
20
|
+
for (const result of results) {
|
|
21
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
22
|
+
findings.push(result.value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return findings;
|
|
26
|
+
}
|
|
27
|
+
parseDeps(appPath) {
|
|
28
|
+
const reqPath = join(appPath, 'requirements.txt');
|
|
29
|
+
if (existsSync(reqPath)) {
|
|
30
|
+
return this.parseRequirementsTxt(readFileSync(reqPath, 'utf-8'));
|
|
31
|
+
}
|
|
32
|
+
const pyprojectPath = join(appPath, 'pyproject.toml');
|
|
33
|
+
if (existsSync(pyprojectPath)) {
|
|
34
|
+
return this.parsePyprojectToml(readFileSync(pyprojectPath, 'utf-8'));
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
parseRequirementsTxt(content) {
|
|
39
|
+
return content
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map(line => line.trim())
|
|
42
|
+
.filter(line => line && !line.startsWith('#') && !line.startsWith('-'))
|
|
43
|
+
.map(line => {
|
|
44
|
+
const match = line.match(/^([a-zA-Z0-9_-]+)==([^\s;]+)/);
|
|
45
|
+
if (!match)
|
|
46
|
+
return null;
|
|
47
|
+
return [match[1], match[2]];
|
|
48
|
+
})
|
|
49
|
+
.filter((entry) => entry !== null);
|
|
50
|
+
}
|
|
51
|
+
parsePyprojectToml(content) {
|
|
52
|
+
const deps = [];
|
|
53
|
+
const depMatch = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
|
|
54
|
+
if (!depMatch)
|
|
55
|
+
return deps;
|
|
56
|
+
const lines = depMatch[1].split('\n');
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
const match = line.match(/"([a-zA-Z0-9_-]+)==([^"]+)"/);
|
|
59
|
+
if (match)
|
|
60
|
+
deps.push([match[1], match[2]]);
|
|
61
|
+
}
|
|
62
|
+
return deps;
|
|
63
|
+
}
|
|
64
|
+
async checkPackage(appName, name, current) {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`);
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
return null;
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
const latest = data.info.version;
|
|
71
|
+
if (current === latest)
|
|
72
|
+
return null;
|
|
73
|
+
const severity = severityFromVersionDelta(current, latest, this.overrides);
|
|
74
|
+
if (severity === 'info')
|
|
75
|
+
return null;
|
|
76
|
+
return {
|
|
77
|
+
appName,
|
|
78
|
+
source: 'pip',
|
|
79
|
+
severity,
|
|
80
|
+
category: 'outdated-dep',
|
|
81
|
+
title: `${name} ${current} -> ${latest}`,
|
|
82
|
+
detail: `Python package ${name} can be updated from ${current} to ${latest}`,
|
|
83
|
+
package: name,
|
|
84
|
+
currentVersion: current,
|
|
85
|
+
latestVersion: latest,
|
|
86
|
+
fixable: true,
|
|
87
|
+
updatedAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AppEntry } from '../../registry.js';
|
|
2
|
+
import type { Collector, Finding } from '../types.js';
|
|
3
|
+
export declare class VulnerabilityCollector implements Collector {
|
|
4
|
+
type: "vulnerability";
|
|
5
|
+
detect(appPath: string): boolean;
|
|
6
|
+
collect(app: AppEntry): Promise<Finding[]>;
|
|
7
|
+
private extractPackages;
|
|
8
|
+
private queryOsv;
|
|
9
|
+
}
|