@modelstatus/cli 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 +124 -0
- package/package.json +55 -0
- package/src/api.js +85 -0
- package/src/auth.js +44 -0
- package/src/config.js +63 -0
- package/src/detect/core.js +58 -0
- package/src/index.js +337 -0
- package/src/openUrl.js +29 -0
- package/src/redact.js +28 -0
- package/src/registry/fetch.js +93 -0
- package/src/registry/local.js +34 -0
- package/src/registry/root-keys.js +12 -0
- package/src/registry/sign.js +17 -0
- package/src/registry/verify.js +67 -0
- package/src/scan.js +15 -0
- package/src/sources/aws.js +63 -0
- package/src/sources/configscan.js +88 -0
- package/src/sources/env.js +21 -0
- package/src/sources/filesystem.js +95 -0
- package/src/sources/helm.js +42 -0
- package/src/sources/index.js +63 -0
- package/src/sources/k8s.js +51 -0
- package/src/sources/shell.js +35 -0
- package/src/sources/sql.js +47 -0
- package/src/tui/app.js +139 -0
- package/src/tui/ui.js +47 -0
- package/src/tui/views/account.js +76 -0
- package/src/tui/views/add.js +84 -0
- package/src/tui/views/alerts.js +160 -0
- package/src/tui/views/inventory.js +102 -0
- package/src/tui/views/scan.js +125 -0
- package/src/tui/views/whatsnew.js +177 -0
- package/src/upgrade.js +29 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { hasCmd, run } from "./shell.js";
|
|
2
|
+
import { scanConfigEntries, entriesFromKV } from "./configscan.js";
|
|
3
|
+
|
|
4
|
+
/* Pure parsers (unit-tested) — keep all JSON shape knowledge here. */
|
|
5
|
+
export function parseSecretList(stdout) {
|
|
6
|
+
try {
|
|
7
|
+
return (JSON.parse(stdout).SecretList || []).map((s) => s.Name).filter(Boolean);
|
|
8
|
+
} catch {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function parseSecretValue(stdout) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(stdout).SecretString ?? null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function parseSsm(stdout) {
|
|
20
|
+
try {
|
|
21
|
+
return (JSON.parse(stdout).Parameters || []).map((p) => ({ name: p.Name, value: p.Value }));
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** AWS Secrets Manager + SSM Parameter Store. Shells out to your already-
|
|
28
|
+
* authenticated `aws` CLI, read-only. Secret VALUES are scanned locally for
|
|
29
|
+
* model ids and never uploaded. opts: { region }. */
|
|
30
|
+
export const awsSecretsSource = {
|
|
31
|
+
id: "aws-secrets",
|
|
32
|
+
label: "AWS Secrets Manager + SSM",
|
|
33
|
+
async available() {
|
|
34
|
+
return hasCmd("aws");
|
|
35
|
+
},
|
|
36
|
+
async collect(opts, compiled) {
|
|
37
|
+
const region = opts?.region ? ["--region", opts.region] : [];
|
|
38
|
+
const tag = opts?.region || "default";
|
|
39
|
+
const out = [];
|
|
40
|
+
|
|
41
|
+
const list = await run("aws", ["secretsmanager", "list-secrets", "--output", "json", ...region]);
|
|
42
|
+
if (list.ok) {
|
|
43
|
+
for (const name of parseSecretList(list.stdout)) {
|
|
44
|
+
const v = await run("aws", ["secretsmanager", "get-secret-value", "--secret-id", name, "--output", "json", ...region]);
|
|
45
|
+
const val = v.ok ? parseSecretValue(v.stdout) : null;
|
|
46
|
+
if (val == null) continue;
|
|
47
|
+
const entries = entriesFromKV(name.split("/").pop(), val, `aws-secrets://${tag}/${name}`, name);
|
|
48
|
+
out.push(...scanConfigEntries(entries, compiled, { sourceType: "aws-secrets" }));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ssm = await run("aws", [
|
|
53
|
+
"ssm", "get-parameters-by-path", "--path", "/", "--recursive", "--with-decryption", "--output", "json", ...region,
|
|
54
|
+
]);
|
|
55
|
+
if (ssm.ok) {
|
|
56
|
+
for (const p of parseSsm(ssm.stdout)) {
|
|
57
|
+
const entries = entriesFromKV(p.name.split("/").pop(), p.value, `aws-ssm://${tag}${p.name}`, p.name);
|
|
58
|
+
out.push(...scanConfigEntries(entries, compiled, { sourceType: "aws-ssm" }));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Shared config/secret scanning for the non-file sources (env, aws, k8s, helm,
|
|
2
|
+
* sql). Turns key/value config — including JSON-stringified and nested values —
|
|
3
|
+
* into normalized model Candidates, running the registry detector over each
|
|
4
|
+
* leaf and REDACTING every snippet so secret values never leave the machine.
|
|
5
|
+
*
|
|
6
|
+
* We only ever emit model-id strings (non-sensitive identifiers like "gpt-4o");
|
|
7
|
+
* provider API keys and other secrets are detected-around but never extracted. */
|
|
8
|
+
import { detectInLine } from "../detect/core.js";
|
|
9
|
+
import { redactValue } from "../redact.js";
|
|
10
|
+
|
|
11
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
12
|
+
|
|
13
|
+
/** Guess an environment label from any contextual hints (namespace, key, path). */
|
|
14
|
+
export function guessEnvFrom(...hints) {
|
|
15
|
+
const s = hints.filter(Boolean).join(" ").toLowerCase();
|
|
16
|
+
if (/(^|[^a-z])(dev|development|sandbox|local)([^a-z]|$)/.test(s)) return "dev";
|
|
17
|
+
if (/(^|[^a-z])(stag|staging|stg|qa|uat|test)([^a-z]|$)/.test(s)) return "staging";
|
|
18
|
+
return "prod";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Flatten a nested object/array into leaf entries with dotted locator paths. */
|
|
22
|
+
export function flattenConfig(obj, baseLocator, envHint, depth = 0) {
|
|
23
|
+
const out = [];
|
|
24
|
+
const join = (loc, seg) => (loc.includes("#") ? `${loc}.${seg}` : `${loc}#${seg}`);
|
|
25
|
+
const walk = (node, locator, d) => {
|
|
26
|
+
if (node == null) return;
|
|
27
|
+
if (d > 10) return;
|
|
28
|
+
if (Array.isArray(node)) {
|
|
29
|
+
node.forEach((x, i) => walk(x, `${locator}[${i}]`, d + 1));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (typeof node === "object") {
|
|
33
|
+
for (const [k, v] of Object.entries(node)) walk(v, locator.includes("#") ? `${locator}.${k}` : `${locator}#${k}`, d + 1);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
out.push({ key: locator.split(/[#.]/).pop(), value: String(node), locator, envHint });
|
|
37
|
+
};
|
|
38
|
+
// Seed: object/array at the base.
|
|
39
|
+
if (Array.isArray(obj)) obj.forEach((x, i) => walk(x, `${baseLocator}#[${i}]`, depth + 1));
|
|
40
|
+
else if (isPlainObject(obj)) for (const [k, v] of Object.entries(obj)) walk(v, join(baseLocator, k), depth + 1);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Expand a single key/value into entries, JSON-decoding stringified objects. */
|
|
45
|
+
export function entriesFromKV(key, value, locator, envHint) {
|
|
46
|
+
let v = value;
|
|
47
|
+
if (typeof v === "string") {
|
|
48
|
+
const t = v.trim();
|
|
49
|
+
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
|
50
|
+
try {
|
|
51
|
+
v = JSON.parse(t);
|
|
52
|
+
} catch {
|
|
53
|
+
/* not JSON — treat as scalar */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (isPlainObject(v) || Array.isArray(v)) return flattenConfig(v, locator, envHint);
|
|
58
|
+
return [{ key, value: v == null ? "" : String(v), locator, envHint }];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run the detector over config entries → model Candidates (redacted snippets).
|
|
62
|
+
* entries: [{ key, value, locator, envHint }] */
|
|
63
|
+
export function scanConfigEntries(entries, compiled, { sourceType, env }) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
const value = e.value == null ? "" : String(e.value);
|
|
68
|
+
const line = `${e.key ?? ""}=${value}`;
|
|
69
|
+
const found = detectInLine(line, compiled);
|
|
70
|
+
if (!found.size) continue;
|
|
71
|
+
const environment = env || guessEnvFrom(e.envHint, e.key, e.locator);
|
|
72
|
+
for (const model_string of found) {
|
|
73
|
+
const key = `${model_string}|${e.locator}`;
|
|
74
|
+
if (seen.has(key)) continue;
|
|
75
|
+
seen.add(key);
|
|
76
|
+
out.push({
|
|
77
|
+
model_string,
|
|
78
|
+
source_type: sourceType,
|
|
79
|
+
location_label: e.locator,
|
|
80
|
+
source_path: e.locator,
|
|
81
|
+
source_line: null,
|
|
82
|
+
environment,
|
|
83
|
+
snippet: redactValue(`${e.key ?? ""}=${value}`).slice(0, 160),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { scanConfigEntries } from "./configscan.js";
|
|
2
|
+
|
|
3
|
+
/** Live process environment — the "env vars not on disk" case. Catches model
|
|
4
|
+
* ids set at runtime (OPENAI_MODEL=gpt-4o, MODEL=claude-3-5-sonnet, …) that a
|
|
5
|
+
* filesystem scan can't see. Run it inside the deployed context to inventory it. */
|
|
6
|
+
export const envSource = {
|
|
7
|
+
id: "env",
|
|
8
|
+
label: "Environment variables (live process)",
|
|
9
|
+
async available() {
|
|
10
|
+
return true;
|
|
11
|
+
},
|
|
12
|
+
async collect(_opts, compiled) {
|
|
13
|
+
const entries = Object.entries(process.env).map(([key, value]) => ({
|
|
14
|
+
key,
|
|
15
|
+
value,
|
|
16
|
+
locator: `env://process/${key}`,
|
|
17
|
+
envHint: process.env.NODE_ENV || process.env.APP_ENV || "",
|
|
18
|
+
}));
|
|
19
|
+
return scanConfigEntries(entries, compiled, { sourceType: "env" });
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { compilePatterns, detectInLine } from "../detect/core.js";
|
|
4
|
+
import { redactValue } from "../redact.js";
|
|
5
|
+
|
|
6
|
+
const SKIP_DIRS = new Set([
|
|
7
|
+
"node_modules", ".git", ".next", "dist", "build", "out", "vendor", ".turbo",
|
|
8
|
+
"coverage", ".pglite", ".venv", "venv", "__pycache__", ".idea", ".vscode",
|
|
9
|
+
]);
|
|
10
|
+
const TEXT_EXT = new Set([
|
|
11
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rb", ".java",
|
|
12
|
+
".rs", ".php", ".cs", ".yaml", ".yml", ".json", ".toml", ".ipynb", ".env",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function guessEnv(file) {
|
|
16
|
+
const f = file.toLowerCase();
|
|
17
|
+
if (/(test|spec|experimental|sandbox|scratch|\/dev\/|\.dev\.)/.test(f)) return "dev";
|
|
18
|
+
if (/stag/.test(f)) return "staging";
|
|
19
|
+
return "prod";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Recursively collect candidate source files, skipping noise dirs. */
|
|
23
|
+
function walk(root) {
|
|
24
|
+
const files = [];
|
|
25
|
+
(function rec(dir) {
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
const full = path.join(dir, e.name);
|
|
34
|
+
if (e.isDirectory()) {
|
|
35
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) rec(full);
|
|
36
|
+
} else {
|
|
37
|
+
const ext = path.extname(e.name);
|
|
38
|
+
if (TEXT_EXT.has(ext) || e.name.startsWith(".env")) files.push(full);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
})(root);
|
|
42
|
+
return files;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The filesystem source: walk a directory tree and emit normalized Candidates. */
|
|
46
|
+
export const filesystemSource = {
|
|
47
|
+
id: "filesystem",
|
|
48
|
+
label: "Filesystem",
|
|
49
|
+
async available() {
|
|
50
|
+
return true;
|
|
51
|
+
},
|
|
52
|
+
/** opts: { root } → Candidate[] */
|
|
53
|
+
async collect({ root }, compiled) {
|
|
54
|
+
const files = walk(root);
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
let content;
|
|
59
|
+
try {
|
|
60
|
+
content = fs.readFileSync(file, "utf8");
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (content.length > 2_000_000) continue;
|
|
65
|
+
const rel = path.relative(root, file);
|
|
66
|
+
content.split(/\r?\n/).forEach((line, idx) => {
|
|
67
|
+
for (const modelStr of detectInLine(line, compiled)) {
|
|
68
|
+
const key = `${rel}:${idx + 1}:${modelStr}`;
|
|
69
|
+
if (seen.has(key)) return;
|
|
70
|
+
seen.add(key);
|
|
71
|
+
out.push({
|
|
72
|
+
model_string: modelStr,
|
|
73
|
+
source_type: "file",
|
|
74
|
+
file: rel,
|
|
75
|
+
line: idx + 1,
|
|
76
|
+
location_label: `${rel}:${idx + 1}`,
|
|
77
|
+
source_path: rel,
|
|
78
|
+
source_line: idx + 1,
|
|
79
|
+
environment: guessEnv(rel),
|
|
80
|
+
snippet: redactValue(line.trim().slice(0, 160)),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Convenience: compile patterns + run the filesystem source. */
|
|
90
|
+
export async function scanFilesystem(root, patterns) {
|
|
91
|
+
const compiled = compilePatterns(patterns);
|
|
92
|
+
return filesystemSource.collect({ root }, compiled);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { guessEnv };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { hasCmd, run } from "./shell.js";
|
|
2
|
+
import { scanConfigEntries, flattenConfig } from "./configscan.js";
|
|
3
|
+
|
|
4
|
+
/** Pure parser: `helm list -A -o json` → [{ name, namespace }]. */
|
|
5
|
+
export function parseHelmList(stdout) {
|
|
6
|
+
try {
|
|
7
|
+
const j = JSON.parse(stdout);
|
|
8
|
+
return (Array.isArray(j) ? j : []).map((r) => ({ name: r.name, namespace: r.namespace })).filter((r) => r.name);
|
|
9
|
+
} catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Helm release values via your authenticated `helm`, read-only. Reads the
|
|
15
|
+
* merged values of each release (`helm get values -o json`) and scans them for
|
|
16
|
+
* model ids. On-disk chart values.yaml/templates are already covered by the
|
|
17
|
+
* filesystem source when you scan the chart directory. */
|
|
18
|
+
export const helmSource = {
|
|
19
|
+
id: "helm",
|
|
20
|
+
label: "Helm release values",
|
|
21
|
+
async available() {
|
|
22
|
+
return hasCmd("helm");
|
|
23
|
+
},
|
|
24
|
+
async collect(_opts, compiled) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const list = await run("helm", ["list", "-A", "-o", "json"]);
|
|
27
|
+
for (const r of parseHelmList(list.ok ? list.stdout : "[]")) {
|
|
28
|
+
const v = await run("helm", ["get", "values", r.name, "-n", r.namespace, "-o", "json"]);
|
|
29
|
+
if (!v.ok) continue;
|
|
30
|
+
let vals;
|
|
31
|
+
try {
|
|
32
|
+
vals = JSON.parse(v.stdout);
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!vals || typeof vals !== "object") continue;
|
|
37
|
+
const entries = flattenConfig(vals, `helm://${r.namespace}/${r.name}`, r.namespace);
|
|
38
|
+
out.push(...scanConfigEntries(entries, compiled, { sourceType: "helm" }));
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { compilePatterns } from "../detect/core.js";
|
|
2
|
+
import { filesystemSource } from "./filesystem.js";
|
|
3
|
+
import { envSource } from "./env.js";
|
|
4
|
+
import { awsSecretsSource } from "./aws.js";
|
|
5
|
+
import { k8sSource } from "./k8s.js";
|
|
6
|
+
import { helmSource } from "./helm.js";
|
|
7
|
+
import { sqlSource } from "./sql.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A Source discovers AI-model usage from one place and emits normalized Candidates:
|
|
11
|
+
* { model_string, source_type, location_label, source_path, source_line?, environment, snippet }
|
|
12
|
+
*
|
|
13
|
+
* Interface:
|
|
14
|
+
* id: string
|
|
15
|
+
* label: string
|
|
16
|
+
* available(opts): Promise<boolean> // is the backing tool/creds present?
|
|
17
|
+
* collect(opts, compiled): Promise<Candidate[]>
|
|
18
|
+
*
|
|
19
|
+
* The secrets/config sources (env, aws-secrets, k8s, helm, sql) each shell out to
|
|
20
|
+
* an ALREADY-AUTHENTICATED CLI, run read-only, scan locally, and REDACT every
|
|
21
|
+
* snippet — only non-sensitive model ids ever leave the machine, never secrets.
|
|
22
|
+
*/
|
|
23
|
+
const SOURCES = [filesystemSource, envSource, awsSecretsSource, k8sSource, helmSource, sqlSource];
|
|
24
|
+
|
|
25
|
+
export const ALL_SOURCE_IDS = SOURCES.map((s) => s.id);
|
|
26
|
+
|
|
27
|
+
export function listSources() {
|
|
28
|
+
return SOURCES;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getSource(id) {
|
|
32
|
+
return SOURCES.find((s) => s.id === id) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Which of the requested sources are usable right now (tool/creds present). */
|
|
36
|
+
export async function availability(sourceIds, opts = {}) {
|
|
37
|
+
const ids = sourceIds && sourceIds.length ? sourceIds : ["filesystem"];
|
|
38
|
+
const report = [];
|
|
39
|
+
for (const id of ids) {
|
|
40
|
+
const src = getSource(id);
|
|
41
|
+
report.push({ id, label: src?.label ?? id, available: src ? await src.available(opts) : false, known: !!src });
|
|
42
|
+
}
|
|
43
|
+
return report;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run a set of sources, returning a flat, de-duplicated Candidate[]. */
|
|
47
|
+
export async function collectFrom(sourceIds, opts, patterns) {
|
|
48
|
+
const compiled = compilePatterns(patterns);
|
|
49
|
+
const ids = sourceIds && sourceIds.length ? sourceIds : ["filesystem"];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const id of ids) {
|
|
53
|
+
const src = getSource(id);
|
|
54
|
+
if (!src || !(await src.available(opts))) continue;
|
|
55
|
+
for (const c of await src.collect(opts, compiled)) {
|
|
56
|
+
const key = `${c.model_string}|${c.location_label}`;
|
|
57
|
+
if (seen.has(key)) continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
out.push(c);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { hasCmd, run } from "./shell.js";
|
|
2
|
+
import { scanConfigEntries, entriesFromKV } from "./configscan.js";
|
|
3
|
+
|
|
4
|
+
const b64decode = (s) => {
|
|
5
|
+
try {
|
|
6
|
+
return Buffer.from(s, "base64").toString("utf8");
|
|
7
|
+
} catch {
|
|
8
|
+
return s;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Pure parser: a `kubectl get … -o json` List → config entries.
|
|
13
|
+
* Secret `data` is base64; ConfigMap `data` is plaintext. */
|
|
14
|
+
export function extractK8sEntries(json) {
|
|
15
|
+
let obj;
|
|
16
|
+
try {
|
|
17
|
+
obj = typeof json === "string" ? JSON.parse(json) : json;
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const entries = [];
|
|
22
|
+
for (const it of obj?.items || []) {
|
|
23
|
+
const kind = (it.kind || "").toLowerCase();
|
|
24
|
+
const ns = it.metadata?.namespace || "default";
|
|
25
|
+
const name = it.metadata?.name || "";
|
|
26
|
+
const isSecret = /secret/.test(kind);
|
|
27
|
+
const data = { ...(it.data || {}), ...(it.stringData || {}) };
|
|
28
|
+
for (const [k, raw] of Object.entries(data)) {
|
|
29
|
+
const value = isSecret ? b64decode(raw) : raw;
|
|
30
|
+
entries.push(...entriesFromKV(k, value, `k8s://${ns}/${kind || "obj"}/${name}#${k}`, ns));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return entries;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Kubernetes Secrets + ConfigMaps via your authenticated `kubectl`, read-only.
|
|
37
|
+
* opts: { namespace, kubeContext }. Defaults to all namespaces. */
|
|
38
|
+
export const k8sSource = {
|
|
39
|
+
id: "k8s",
|
|
40
|
+
label: "Kubernetes secrets + configmaps",
|
|
41
|
+
async available() {
|
|
42
|
+
return hasCmd("kubectl");
|
|
43
|
+
},
|
|
44
|
+
async collect(opts, compiled) {
|
|
45
|
+
const ctx = opts?.kubeContext ? ["--context", opts.kubeContext] : [];
|
|
46
|
+
const nsArgs = opts?.namespace ? ["-n", opts.namespace] : ["-A"];
|
|
47
|
+
const res = await run("kubectl", ["get", "secrets,configmaps", ...nsArgs, "-o", "json", ...ctx]);
|
|
48
|
+
if (!res.ok) return [];
|
|
49
|
+
return scanConfigEntries(extractK8sEntries(res.stdout), compiled, { sourceType: "k8s" });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Is `name` an executable on PATH? Pure PATH scan — no shell, no spawn. */
|
|
6
|
+
export function hasCmd(name) {
|
|
7
|
+
const PATH = process.env.PATH || "";
|
|
8
|
+
for (const dir of PATH.split(path.delimiter)) {
|
|
9
|
+
if (!dir) continue;
|
|
10
|
+
try {
|
|
11
|
+
fs.accessSync(path.join(dir, name), fs.constants.X_OK);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
/* keep looking */
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Run a command WITHOUT a shell (execFile → no injection). Read-only by
|
|
21
|
+
* convention. Never throws; resolves { ok, stdout, stderr, code }. */
|
|
22
|
+
export function run(cmd, args, { timeout = 25000, input, maxBuffer = 48 * 1024 * 1024 } = {}) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const child = execFile(cmd, args, { timeout, maxBuffer }, (err, stdout, stderr) => {
|
|
25
|
+
resolve({ ok: !err, stdout: stdout || "", stderr: stderr || "", code: err?.code ?? 0 });
|
|
26
|
+
});
|
|
27
|
+
if (input != null) {
|
|
28
|
+
try {
|
|
29
|
+
child.stdin.end(input);
|
|
30
|
+
} catch {
|
|
31
|
+
/* ignore */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { hasCmd, run } from "./shell.js";
|
|
2
|
+
import { detectInLine } from "../detect/core.js";
|
|
3
|
+
import { redactValue } from "../redact.js";
|
|
4
|
+
|
|
5
|
+
const isPg = (dsn) => /^postgres(ql)?:\/\//.test(dsn || "");
|
|
6
|
+
const safeIdent = (s) => String(s || "").replace(/[^A-Za-z0-9_.]/g, "");
|
|
7
|
+
|
|
8
|
+
/** Opt-in SQL/DB config scan. Requires --db <postgres-dsn> and the `psql` client.
|
|
9
|
+
* Runs a READ-ONLY SELECT over a config/settings table and scans the textual
|
|
10
|
+
* output for model ids. opts: { db, sqlTable, env }. */
|
|
11
|
+
export const sqlSource = {
|
|
12
|
+
id: "sql",
|
|
13
|
+
label: "SQL / DB config",
|
|
14
|
+
async available(opts) {
|
|
15
|
+
return !!(opts && opts.db && isPg(opts.db)) && hasCmd("psql");
|
|
16
|
+
},
|
|
17
|
+
async collect(opts, compiled) {
|
|
18
|
+
if (!opts?.db || !isPg(opts.db)) return [];
|
|
19
|
+
const table = safeIdent(opts.sqlTable || "settings");
|
|
20
|
+
if (!table) return [];
|
|
21
|
+
// Force read-only so a bad table/DSN can never mutate anything.
|
|
22
|
+
const sql = `SET default_transaction_read_only=on; SELECT * FROM ${table};`;
|
|
23
|
+
const res = await run("psql", [opts.db, "-A", "-t", "-c", sql]);
|
|
24
|
+
if (!res.ok) return [];
|
|
25
|
+
|
|
26
|
+
const out = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
res.stdout.split(/\r?\n/).forEach((line, i) => {
|
|
29
|
+
for (const model_string of detectInLine(line, compiled)) {
|
|
30
|
+
const locator = `sql://${table}#row${i + 1}`;
|
|
31
|
+
const key = `${model_string}|${locator}`;
|
|
32
|
+
if (seen.has(key)) return;
|
|
33
|
+
seen.add(key);
|
|
34
|
+
out.push({
|
|
35
|
+
model_string,
|
|
36
|
+
source_type: "sql",
|
|
37
|
+
location_label: locator,
|
|
38
|
+
source_path: locator,
|
|
39
|
+
source_line: i + 1,
|
|
40
|
+
environment: opts.env || "prod",
|
|
41
|
+
snippet: redactValue(line.trim()).slice(0, 160),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return out;
|
|
46
|
+
},
|
|
47
|
+
};
|
package/src/tui/app.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, Box, Text, useInput, useApp } from "ink";
|
|
3
|
+
import { createClient } from "../api.js";
|
|
4
|
+
import { h, useAsync } from "./ui.js";
|
|
5
|
+
import { InventoryView } from "./views/inventory.js";
|
|
6
|
+
import { ScanView } from "./views/scan.js";
|
|
7
|
+
import { WhatsNewView } from "./views/whatsnew.js";
|
|
8
|
+
import { AddView } from "./views/add.js";
|
|
9
|
+
import { AlertsView } from "./views/alerts.js";
|
|
10
|
+
import { AccountView } from "./views/account.js";
|
|
11
|
+
|
|
12
|
+
const VIEWS = [
|
|
13
|
+
{ key: "inventory", label: "Inventory", Comp: InventoryView },
|
|
14
|
+
{ key: "scan", label: "Scan", Comp: ScanView },
|
|
15
|
+
{ key: "whatsnew", label: "What's New", Comp: WhatsNewView },
|
|
16
|
+
{ key: "add", label: "Add", Comp: AddView },
|
|
17
|
+
{ key: "alerts", label: "Alerts", Comp: AlertsView },
|
|
18
|
+
{ key: "account", label: "Account", Comp: AccountView },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function Header({ idx, accountName, plan }) {
|
|
22
|
+
return h(
|
|
23
|
+
Box,
|
|
24
|
+
{ flexDirection: "column" },
|
|
25
|
+
h(
|
|
26
|
+
Box,
|
|
27
|
+
{ justifyContent: "space-between" },
|
|
28
|
+
h(Text, { color: "cyan", bold: true }, " LLM Status "),
|
|
29
|
+
h(
|
|
30
|
+
Text,
|
|
31
|
+
{ color: "gray" },
|
|
32
|
+
`${accountName ?? "…"}${plan ? ` · ${plan}` : ""} `,
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
h(
|
|
36
|
+
Box,
|
|
37
|
+
{},
|
|
38
|
+
...VIEWS.map((v, i) =>
|
|
39
|
+
h(
|
|
40
|
+
Text,
|
|
41
|
+
{ key: v.key, color: i === idx ? "black" : "gray", backgroundColor: i === idx ? "cyan" : undefined },
|
|
42
|
+
` ${i + 1} ${v.label} `,
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function PromptBar({ prompt }) {
|
|
50
|
+
if (!prompt) return null;
|
|
51
|
+
return h(
|
|
52
|
+
Box,
|
|
53
|
+
{ borderStyle: "round", borderColor: "yellow", paddingX: 1 },
|
|
54
|
+
h(Text, { color: "yellow" }, `${prompt.label}: `),
|
|
55
|
+
h(Text, {}, prompt.value),
|
|
56
|
+
h(Text, { color: "gray" }, "▏"),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function App({ apiBase, apiKey, dir, initialView }) {
|
|
61
|
+
const { exit } = useApp();
|
|
62
|
+
const client = React.useMemo(() => createClient({ apiBase, apiKey }), [apiBase, apiKey]);
|
|
63
|
+
const startIdx = Math.max(0, VIEWS.findIndex((v) => v.key === initialView));
|
|
64
|
+
const [idx, setIdx] = React.useState(startIdx);
|
|
65
|
+
const [toast, setToast] = React.useState(null);
|
|
66
|
+
const [prompt, setPrompt] = React.useState(null);
|
|
67
|
+
const me = useAsync(() => client.me(), []);
|
|
68
|
+
|
|
69
|
+
const showToast = React.useCallback((msg, color = "green") => {
|
|
70
|
+
setToast({ msg, color });
|
|
71
|
+
setTimeout(() => setToast(null), 2500);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const askPrompt = React.useCallback(
|
|
75
|
+
(label, { initial = "", onSubmit, onCancel } = {}) =>
|
|
76
|
+
setPrompt({ label, value: initial, onSubmit, onCancel }),
|
|
77
|
+
[],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Global keys — paused while a prompt is capturing text.
|
|
81
|
+
useInput(
|
|
82
|
+
(input, key) => {
|
|
83
|
+
if (key.ctrl && input === "c") return exit();
|
|
84
|
+
if (/[1-6]/.test(input)) return setIdx(Number(input) - 1);
|
|
85
|
+
if (key.tab) return setIdx((i) => (i + 1) % VIEWS.length);
|
|
86
|
+
if (input === "q") return exit();
|
|
87
|
+
},
|
|
88
|
+
{ isActive: !prompt },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Prompt capture.
|
|
92
|
+
useInput(
|
|
93
|
+
(input, key) => {
|
|
94
|
+
if (key.escape) {
|
|
95
|
+
const cb = prompt.onCancel;
|
|
96
|
+
setPrompt(null);
|
|
97
|
+
cb?.();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (key.return) {
|
|
101
|
+
const { onSubmit, value } = prompt;
|
|
102
|
+
setPrompt(null);
|
|
103
|
+
onSubmit?.(value);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (key.backspace || key.delete) return setPrompt((p) => ({ ...p, value: p.value.slice(0, -1) }));
|
|
107
|
+
if (input && !key.ctrl && !key.meta) setPrompt((p) => ({ ...p, value: p.value + input }));
|
|
108
|
+
},
|
|
109
|
+
{ isActive: !!prompt },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const ui = { showToast, askPrompt, switchTo: (key) => setIdx(VIEWS.findIndex((v) => v.key === key)) };
|
|
113
|
+
const View = VIEWS[idx].Comp;
|
|
114
|
+
const account = me.data?.account ?? null;
|
|
115
|
+
|
|
116
|
+
return h(
|
|
117
|
+
Box,
|
|
118
|
+
{ flexDirection: "column", paddingX: 1 },
|
|
119
|
+
h(Header, { idx, accountName: account?.name, plan: account?.plan }),
|
|
120
|
+
h(
|
|
121
|
+
Box,
|
|
122
|
+
{ flexDirection: "column", marginTop: 1, minHeight: 14 },
|
|
123
|
+
h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, active: !prompt }),
|
|
124
|
+
),
|
|
125
|
+
h(PromptBar, { prompt }),
|
|
126
|
+
h(
|
|
127
|
+
Box,
|
|
128
|
+
{ marginTop: 0 },
|
|
129
|
+
toast
|
|
130
|
+
? h(Text, { color: toast.color }, ` ${toast.msg}`)
|
|
131
|
+
: h(Text, { color: "gray" }, " 1-6/Tab switch · q quit · keys per view shown above"),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function runApp(opts) {
|
|
137
|
+
const app = render(h(App, opts));
|
|
138
|
+
return app.waitUntilExit();
|
|
139
|
+
}
|