crewforth 3.1.1 → 3.2.0-rc.2
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/CHANGELOG.md +171 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +56 -5
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +14 -13
- package/kit/agents/crew-commit-agent.md +7 -2
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +117 -6
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +15 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/smoke-test.sh +576 -41
- package/kit/hooks/agent-outcome.sh +26 -12
- package/kit/hooks/guard-agent-model.sh +109 -9
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +12 -0
- package/kit/studio/server/index.js +125 -11
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +223 -15
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +131 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +10 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// surface.mjs — what a project touches outside its own modules: external resources, configuration, endpoints
|
|
2
|
+
// and containers. Patterns, not compilers: a route built at run time, a config key assembled from parts or a URL
|
|
3
|
+
// read from somewhere else is not seen, and the scanner says which families it does not read (notMeasured).
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parseJsonc, parseYaml, yGet, Unsupported } from './parse.mjs';
|
|
7
|
+
import { ev, isTestPath, isLocalHost, risky } from './project.mjs';
|
|
8
|
+
|
|
9
|
+
const P = path.posix;
|
|
10
|
+
const dirOf = (f) => { const d = P.dirname(f); return d === '.' ? '.' : d; };
|
|
11
|
+
const under = (file, dir) => dir === '.' || file === dir || file.startsWith(dir + '/');
|
|
12
|
+
const isComment = (l) => /^\s*(\/\/|#|\*|\/\*|<!--)/.test(l);
|
|
13
|
+
|
|
14
|
+
// The deepest module holding a path, any stack.
|
|
15
|
+
export function ownerOf(modules, file) {
|
|
16
|
+
return modules.filter((m) => under(file, m.dir)).sort((a, b) => b.dir.length - a.dir.length || (a.id < b.id ? -1 : 1))[0] || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---- external resources ---------------------------------------------------------------------------------------
|
|
20
|
+
export function externals(pj, st, rules, hostOf, out) {
|
|
21
|
+
const ext = new Map();
|
|
22
|
+
const node = (kind, label) => {
|
|
23
|
+
const id = `ext:${kind}:${label}`;
|
|
24
|
+
if (!ext.has(id)) ext.set(id, { id, kind: 'external', resource: kind, label, hosts: new Set(), evidence: [] });
|
|
25
|
+
return ext.get(id);
|
|
26
|
+
};
|
|
27
|
+
const ecoRules = { npm: rules.npm, nuget: rules.nuget, pypi: rules.pypi, go: rules.go, maven: rules.maven };
|
|
28
|
+
const pyNorm = (s) => s.toLowerCase().replace(/[_.]/g, '-');
|
|
29
|
+
for (const m of st.modules) {
|
|
30
|
+
for (const d of m.deps) {
|
|
31
|
+
const table = ecoRules[d.eco]; if (!table) continue;
|
|
32
|
+
let hit = null;
|
|
33
|
+
if (d.eco === 'pypi') { const k = Object.keys(table).find((x) => pyNorm(x) === pyNorm(d.name)); hit = k && table[k]; }
|
|
34
|
+
else if (d.eco === 'go') { const k = Object.keys(table).find((x) => d.name === x || d.name.startsWith(x + '/')); hit = k && table[k]; }
|
|
35
|
+
else hit = table[d.name];
|
|
36
|
+
if (!hit) continue;
|
|
37
|
+
const n = node(hit[0], hit[1]);
|
|
38
|
+
n.evidence.push(d.ev);
|
|
39
|
+
out.edges.push({ from: m.id, to: n.id, kind: `uses-${hit[0]}`, confidence: 'evidence', via: 'package', evidence: [d.ev] });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { ext, node };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A value's scheme or shape, read only to name the kind. Returns [kind, label, host] or null.
|
|
46
|
+
function classify(value, key, rules, hostOf) {
|
|
47
|
+
const v = String(value).trim().replace(/^["']|["']$/g, '').replace(/^jdbc:/i, '');
|
|
48
|
+
const s = /^([a-z][a-z0-9+.-]*):\/\/([^\s"']*)/i.exec(v);
|
|
49
|
+
if (s) {
|
|
50
|
+
const scheme = s[1].toLowerCase();
|
|
51
|
+
if (rules.schemes[scheme]) return [...rules.schemes[scheme], hostOf(v)];
|
|
52
|
+
if (scheme === 'http' || scheme === 'https') return ['http-api', null, hostOf(v)];
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const kv = /(?:^|;)\s*(Host|Server|Data Source|Address|Addr)\s*=\s*([^;]+)/i.exec(v);
|
|
56
|
+
if (kv && /;/.test(v)) {
|
|
57
|
+
const label = /^host$/i.test(kv[1]) ? 'postgres' : /\.db$|\.sqlite3?$/i.test(kv[2]) ? 'sqlite' : 'sql';
|
|
58
|
+
return ['db', label, hostOf(kv[2].replace(/^tcp:/i, '').split(',')[0])];
|
|
59
|
+
}
|
|
60
|
+
if (/connection|conn_?str|database_url|db_url/i.test(key) && v) return ['db', 'sql', null];
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- configuration: where keys are defined, and where code reads them ------------------------------------------
|
|
65
|
+
// defs { key, file, line, source: env|appsettings|properties|yaml|compose|dockerfile, ev }
|
|
66
|
+
// reads { key, prefix: bool, hasDefault: bool, file, line, ev }
|
|
67
|
+
export function configuration(pj, st, rules, hostOf, ex, out) {
|
|
68
|
+
const defs = []; const reads = [];
|
|
69
|
+
const def = (key, file, line, source, value) => {
|
|
70
|
+
// A "key" that is itself a secret (a token pasted on a line of its own) would travel in titles and data, which
|
|
71
|
+
// no quote net covers: the line is not read.
|
|
72
|
+
if (risky(key, out.patterns) >= 0) { out.notMeasured.push({ what: `a line of ${file}`, reason: 'its key looks like a secret, so the line was not read' }); return; }
|
|
73
|
+
const e = ev(file, line, key);
|
|
74
|
+
defs.push({ key, file, line, source, ev: e });
|
|
75
|
+
if (value === undefined || value === null || value === '') return;
|
|
76
|
+
const c = classify(value, key, rules, hostOf);
|
|
77
|
+
if (!c) return;
|
|
78
|
+
if (c[0] === 'http-api' && isLocalHost(c[2])) return;
|
|
79
|
+
const label = c[1] || (c[2] && c[2] !== '[masked]' ? c[2] : 'unknown');
|
|
80
|
+
const n = ex.node(c[0], label);
|
|
81
|
+
if (c[2]) n.hosts.add(c[2]);
|
|
82
|
+
n.evidence.push(e);
|
|
83
|
+
const m = ownerOf(st.modules, file);
|
|
84
|
+
if (m) out.edges.push({ from: m.id, to: n.id, kind: `configured-${c[0]}`, confidence: 'evidence', via: 'config', evidence: [e] });
|
|
85
|
+
if (c[0] === 'http-api') out.configUrls.push({ key, host: c[2], ext: n.id, ev: e });
|
|
86
|
+
};
|
|
87
|
+
for (const f of pj.files) {
|
|
88
|
+
if (isTestPath(f)) continue; // a sample project's configuration is not this project's
|
|
89
|
+
const b = P.basename(f).toLowerCase();
|
|
90
|
+
if (b.startsWith('.env')) {
|
|
91
|
+
const src = pj.read(f); if (!src) continue;
|
|
92
|
+
src.lines.forEach((l, i) => { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$/.exec(l); if (m) def(m[1], f, i + 1, 'env', m[2]); });
|
|
93
|
+
} else if (/^appsettings.*\.json$/.test(b)) {
|
|
94
|
+
const src = pj.read(f); if (!src) continue;
|
|
95
|
+
let doc; try { doc = parseJsonc(src.text); } catch (e) { out.notMeasured.push({ what: f, reason: `could not be read: ${e.message}` }); continue; }
|
|
96
|
+
const walk = (v, at) => {
|
|
97
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) { for (const k of Object.keys(v)) walk(v[k], [...at, k]); return; }
|
|
98
|
+
if (Array.isArray(v)) return; // an array is one value of its key; the key is defined by the parent walk below
|
|
99
|
+
const line = doc.lines.get(at.join('\u0000')) || 1;
|
|
100
|
+
def(at.join(':'), f, line, 'appsettings', typeof v === 'string' ? v : '');
|
|
101
|
+
// The anchor must be on the cited line: the leaf name is, the full path is not.
|
|
102
|
+
defs[defs.length - 1].ev.text = at[at.length - 1];
|
|
103
|
+
};
|
|
104
|
+
walk(doc.value, []);
|
|
105
|
+
} else if (/^application.*\.properties$/.test(b)) {
|
|
106
|
+
const src = pj.read(f); if (!src) continue;
|
|
107
|
+
src.lines.forEach((l, i) => { if (isComment(l) || /^\s*!/.test(l)) return; const m = /^\s*([\w.\-[\]]+)\s*[=:]\s*(.*)$/.exec(l); if (m) def(m[1], f, i + 1, 'properties', m[2]); });
|
|
108
|
+
} else if (/^application.*\.ya?ml$/.test(b)) {
|
|
109
|
+
const src = pj.read(f); if (!src) continue;
|
|
110
|
+
let y; try { y = parseYaml(src.text); } catch (e) { out.notMeasured.push({ what: f, reason: `YAML outside the subset this scanner reads: ${e.message}` }); continue; }
|
|
111
|
+
const walk = (node, at) => {
|
|
112
|
+
if (node.t === 'map') { for (const e of node.e) { if (e.v.t === 'str') { def([...at, e.k].join('.'), f, e.line, 'yaml', e.v.v); defs[defs.length - 1].ev.text = e.k; } else walk(e.v, [...at, e.k]); } }
|
|
113
|
+
};
|
|
114
|
+
walk(y, []);
|
|
115
|
+
} else if (b === 'dockerfile' || b.endsWith('.dockerfile')) {
|
|
116
|
+
const src = pj.read(f); if (!src) continue;
|
|
117
|
+
src.lines.forEach((l, i) => {
|
|
118
|
+
const m = /^\s*(ENV|ARG)\s+([A-Za-z_][A-Za-z0-9_]*)/i.exec(l); if (m) def(m[2], f, i + 1, 'dockerfile');
|
|
119
|
+
const from = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)/i.exec(l);
|
|
120
|
+
if (from) { const o = ownerOf(st.modules, f); if (o) o.tech.add(`image:${from[1]}`); }
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Reads, per language. A read with an inline fallback is not a missing key.
|
|
125
|
+
const READS = [
|
|
126
|
+
{ ext: /\.(m?[jt]sx?|cjs)$/, re: [/process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g, /process\.env\[\s*['"]([^'"]+)['"]\s*\]/g, /import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g], dflt: /\?\?|\|\|/ },
|
|
127
|
+
{ ext: /\.py$/, re: [/os\.environ\[\s*['"]([^'"]+)['"]\s*\]/g, /os\.environ\.get\(\s*['"]([^'"]+)['"]/g, /os\.getenv\(\s*['"]([^'"]+)['"]/g, /env\(\s*['"]([A-Z][A-Z0-9_]*)['"]/g], dflt: /,\s*[^)]/ },
|
|
128
|
+
{ ext: /\.go$/, re: [/os\.(?:Getenv|LookupEnv)\(\s*"([^"]+)"/g] },
|
|
129
|
+
{ ext: /\.cs$/, re: [/Configuration\s*\[\s*"([^"]+)"\s*\]/g, /\.GetValue<[^>]+>\(\s*"([^"]+)"/g, /GetConnectionString\(\s*"([^"]+)"/g, /Environment\.GetEnvironmentVariable\(\s*"([^"]+)"/g, /\b\w*[Cc]onfig\w*\s*\[\s*"([^"]+)"\s*\]/g], dflt: /\?\?/ },
|
|
130
|
+
// environment.getProperty is Spring configuration; System.getProperty is a JVM property (user.home), not a key.
|
|
131
|
+
{ ext: /\.(java|kt)$/, re: [/@Value\(\s*"\$\{([^}:]+)(:[^}]*)?\}"/g, /System\.getenv\(\s*"([^"]+)"/g, /\b(?:env|environment)\.getProperty\(\s*"([^"]+)"/g], dflt: /^\s*,/ },
|
|
132
|
+
];
|
|
133
|
+
const SECTION = [/GetSection\(\s*"([^"]+)"/g, /@ConfigurationProperties\(\s*(?:prefix\s*=\s*)?"([^"]+)"/g];
|
|
134
|
+
for (const m of st.modules) {
|
|
135
|
+
for (const f of m.files) {
|
|
136
|
+
if (isTestPath(f)) continue;
|
|
137
|
+
const lang = READS.find((r) => r.ext.test(f)); if (!lang) continue;
|
|
138
|
+
const src = pj.read(f); if (!src) continue;
|
|
139
|
+
src.lines.forEach((l, i) => {
|
|
140
|
+
if (isComment(l)) return;
|
|
141
|
+
for (const re of lang.re) for (const x of l.matchAll(re)) {
|
|
142
|
+
let key = x[1]; if (/GetConnectionString/.test(x[0])) key = `ConnectionStrings:${key}`;
|
|
143
|
+
const hasDefault = !!x[2] || (lang.dflt ? lang.dflt.test(l.slice(x.index + x[0].length)) : false);
|
|
144
|
+
reads.push({ key, prefix: false, hasDefault, file: f, line: i + 1, ev: ev(f, i + 1, x[1]) });
|
|
145
|
+
}
|
|
146
|
+
for (const re of SECTION) for (const x of l.matchAll(re)) reads.push({ key: x[1], prefix: true, hasDefault: false, file: f, line: i + 1, ev: ev(f, i + 1, x[1]) });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// A schema read by a tool rather than by code: Prisma's env("DATABASE_URL").
|
|
151
|
+
for (const f of pj.files.filter((x) => /\.prisma$/.test(x))) {
|
|
152
|
+
const src = pj.read(f); if (!src) continue;
|
|
153
|
+
src.lines.forEach((l, i) => { for (const x of l.matchAll(/\benv\(\s*"([^"]+)"\s*\)/g)) reads.push({ key: x[1], prefix: false, hasDefault: false, file: f, line: i + 1, ev: ev(f, i + 1, x[1]) }); });
|
|
154
|
+
}
|
|
155
|
+
return { defs, reads };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Two spellings of one key: case, '-' and the separators of .NET (':' and '__'), Spring ('.') and env ('_').
|
|
159
|
+
export const canon = (k) => String(k).toLowerCase().replace(/__|[:._]/g, '.').replace(/-/g, '');
|
|
160
|
+
|
|
161
|
+
// ---- code: literal URLs ---------------------------------------------------------------------------------------
|
|
162
|
+
const NOT_APIS = /(^|\.)(w3\.org|json-schema\.org|schemas\.microsoft\.com|maven\.apache\.org|xmlns\.com|schemas\.xmlsoap\.org|example\.(com|org|net))$/;
|
|
163
|
+
export function codeUrls(pj, st, hostOf, ex, out) {
|
|
164
|
+
for (const m of st.modules) {
|
|
165
|
+
for (const f of m.files) {
|
|
166
|
+
if (isTestPath(f)) continue;
|
|
167
|
+
const src = pj.read(f); if (!src) continue;
|
|
168
|
+
src.lines.forEach((l, i) => {
|
|
169
|
+
if (isComment(l)) return;
|
|
170
|
+
for (const x of l.matchAll(/["'`](https?:\/\/[^"'`\s$]+)/g)) {
|
|
171
|
+
const host = hostOf(x[1]);
|
|
172
|
+
if (!host || NOT_APIS.test(host) || isLocalHost(host)) continue;
|
|
173
|
+
// Cited in the line's own spelling (the host is kept lower-cased), after any userinfo.
|
|
174
|
+
const auth = x[1].slice(x[1].indexOf('://') + 3);
|
|
175
|
+
const spelled = (auth.includes('@') ? auth.slice(auth.lastIndexOf('@') + 1) : auth).split(/[/?#]/)[0];
|
|
176
|
+
const anchor = host === '[masked]' || !spelled ? x[1].slice(0, x[1].indexOf('://') + 3) : spelled;
|
|
177
|
+
const n = ex.node('http-api', host === '[masked]' ? 'unknown' : host);
|
|
178
|
+
n.hosts.add(host);
|
|
179
|
+
const e = ev(f, i + 1, anchor);
|
|
180
|
+
n.evidence.push(e);
|
|
181
|
+
out.edges.push({ from: m.id, to: n.id, kind: 'uses-http-api', confidence: 'evidence', via: 'code', evidence: [e] });
|
|
182
|
+
out.codeHosts.add(host);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- endpoints ----------------------------------------------------------------------------------------------
|
|
190
|
+
// A path is compared in one spelling: lower case, one leading slash, no trailing slash, every parameter ({id},
|
|
191
|
+
// :id, ${id}, <int:id>, {id:int}) as {}. A path whose start was built at run time (a variable before the first
|
|
192
|
+
// literal segment) is kept as written and marked unresolved; it is never matched.
|
|
193
|
+
export function normPath(raw) {
|
|
194
|
+
let p = String(raw).trim();
|
|
195
|
+
let resolved = true;
|
|
196
|
+
// A run-time base in front (`${API}`, `{BASE}`) is dropped; what follows it is the path, if it starts with '/'.
|
|
197
|
+
const lead = /^(\$\{[^}]*\}|\{[A-Za-z_][\w.]*\})/.exec(p);
|
|
198
|
+
if (lead) { p = p.slice(lead[0].length); if (!p.startsWith('/')) resolved = false; }
|
|
199
|
+
const base = lead ? lead[0] : null;
|
|
200
|
+
if (/^https?:\/\//i.test(p)) return null;
|
|
201
|
+
if (p.startsWith('$') || p === '') resolved = false;
|
|
202
|
+
p = p.split(/[?#]/)[0];
|
|
203
|
+
p = p.replace(/\$\{[^}]*\}|\{[^}/]*\}|<[^>/]*>|(^|\/):[A-Za-z_]\w*/g, (s0, lead2) => `${lead2 || ''}{}`);
|
|
204
|
+
if (/\[(controller|action)\]/i.test(p)) resolved = false;
|
|
205
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
206
|
+
p = p.replace(/\/{2,}/g, '/');
|
|
207
|
+
if (p.length > 1) p = p.replace(/\/$/, '');
|
|
208
|
+
return { path: p.toLowerCase(), resolved, base };
|
|
209
|
+
}
|
|
210
|
+
const join = (a, b) => `${a || ''}/${b || ''}`;
|
|
211
|
+
// The first string argument of a call whose `(` ends at `from` on line i: on that line, or, when nothing follows
|
|
212
|
+
// the `(` (a formatter's way with a long call), on the next line that has something on it. `at` is the line the
|
|
213
|
+
// string is on, which is the line cited.
|
|
214
|
+
const ARG = /^\s*(?:path\s*=\s*)?f?r?(['"`])((?:(?!\1)[^\\]|\\.)*)\1/;
|
|
215
|
+
function firstArg(L, i, from) {
|
|
216
|
+
const rest = L[i].slice(from);
|
|
217
|
+
let x = ARG.exec(rest);
|
|
218
|
+
if (x) return { s: x[2], at: i };
|
|
219
|
+
if (rest.trim()) return null;
|
|
220
|
+
for (let j = i + 1; j < Math.min(L.length, i + 4); j++) {
|
|
221
|
+
if (!L[j].trim()) continue;
|
|
222
|
+
x = ARG.exec(L[j]);
|
|
223
|
+
return x ? { s: x[2], at: j } : null;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
// The names a module calls HTTP through: axios, and every `x = axios.create(…)` in any of its files (a client made
|
|
228
|
+
// in lib/api.ts is called from every component). A name a file itself makes a server of is not one there.
|
|
229
|
+
function httpClients(pj, m) {
|
|
230
|
+
const out = new Set(['axios']);
|
|
231
|
+
for (const f of m.files) {
|
|
232
|
+
if (!/\.(m?[jt]sx?|cjs)$/.test(f)) continue;
|
|
233
|
+
const src = pj.read(f); if (!src) continue;
|
|
234
|
+
for (const x of src.text.matchAll(/(\w+)\s*=\s*axios\.create\(/g)) out.add(x[1]);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function endpoints(pj, st, out) {
|
|
240
|
+
const provides = []; const consumes = [];
|
|
241
|
+
const mounts = [];
|
|
242
|
+
let dynamic = 0; // routes a server registers with a path that is not a literal: `app.get(path, …)` in a loop
|
|
243
|
+
const add = (list, m, method, raw, f, i, anchor, unresolved = false) => {
|
|
244
|
+
const n = normPath(raw); if (!n) return;
|
|
245
|
+
if (unresolved) n.resolved = false;
|
|
246
|
+
// A route or a call is cited up to its query: `?apiKey=...` stays out of the document.
|
|
247
|
+
if (/^[/$\{]/.test(anchor)) anchor = anchor.split(/[?#]/)[0] || anchor[0];
|
|
248
|
+
// A route whose own path is empty (`@router.get("")`, Django's `path('', …)`) is cited by the call that declares
|
|
249
|
+
// it: an empty quote is not a piece of the line anyone can check.
|
|
250
|
+
if (!anchor) anchor = /([\w.]+)\(\s*(?:[rf$]?(['"`])\2|$)/.exec(pj.read(f)?.lines[i] ?? '')?.[1] ?? '';
|
|
251
|
+
if (!anchor) return;
|
|
252
|
+
if (/\.[a-z0-9]{2,5}$/i.test(n.path)) return; // a static file (/icons/x.svg, /app.js), not an API route
|
|
253
|
+
const e = { module: m.id, method: method.toUpperCase(), path: n.path, resolved: n.resolved, evidence: [ev(f, i + 1, anchor)] };
|
|
254
|
+
// A call whose host is a run-time value (`${baseURL}/x`, f"{base}/x") may be to this repo or to anyone's: it
|
|
255
|
+
// can find its provider here, but its absence here proves nothing.
|
|
256
|
+
if (n.base) e.base = n.base;
|
|
257
|
+
list.push(e);
|
|
258
|
+
};
|
|
259
|
+
for (const m of st.modules) {
|
|
260
|
+
let moduleClients = null;
|
|
261
|
+
for (const f of m.files) {
|
|
262
|
+
if (isTestPath(f)) continue;
|
|
263
|
+
const src = pj.read(f); if (!src) continue;
|
|
264
|
+
const L = src.lines;
|
|
265
|
+
const text = src.text;
|
|
266
|
+
// .NET: attribute routing and minimal APIs
|
|
267
|
+
if (/\.cs$/.test(f)) {
|
|
268
|
+
let prefix = null; let cls = null; const groups = new Map();
|
|
269
|
+
L.forEach((l, i) => {
|
|
270
|
+
if (isComment(l)) return;
|
|
271
|
+
const r = /^\s*\[Route\(\s*"([^"]*)"\s*\)\]/.exec(l);
|
|
272
|
+
const c = /\bclass\s+(\w+)/.exec(l);
|
|
273
|
+
if (r && !cls) { prefix = r[1]; }
|
|
274
|
+
if (c) { cls = c[1]; if (prefix !== null) prefix = prefix.replace(/\[controller\]/gi, cls.replace(/Controller$/, '')); }
|
|
275
|
+
const h = /^\s*\[Http(Get|Post|Put|Patch|Delete)(?:\(\s*"([^"]*)"\s*\))?\]/.exec(l);
|
|
276
|
+
if (h && cls) add(provides, m, h[1], h[2] && h[2].startsWith('/') ? h[2] : join(prefix, h[2]), f, i, `Http${h[1]}`);
|
|
277
|
+
const g = /(\w+)\s*=\s*(\w+)\.MapGroup\(\s*"([^"]*)"/.exec(l);
|
|
278
|
+
if (g) groups.set(g[1], join(groups.get(g[2]) || '', g[3]));
|
|
279
|
+
const mp = /(\w+)\.Map(Get|Post|Put|Patch|Delete)\(\s*"([^"]*)"/.exec(l);
|
|
280
|
+
if (mp) add(provides, m, mp[2], join(groups.get(mp[1]) || '', mp[3]), f, i, `Map${mp[2]}`);
|
|
281
|
+
const refit = /^\s*\[(Get|Post|Put|Patch|Delete)\(\s*"([^"]+)"\s*\)\]/.exec(l);
|
|
282
|
+
if (refit) add(consumes, m, refit[1], refit[2], f, i, refit[2]);
|
|
283
|
+
const hc = /\.(Get|Post|Put|Patch|Delete)(?:FromJson|AsJson)?Async\(\s*\$?"([^"]*)"/.exec(l);
|
|
284
|
+
if (hc) add(consumes, m, hc[1], hc[2], f, i, hc[2]);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// Node: Express / Fastify / Nest providers; fetch / axios consumers
|
|
288
|
+
if (/\.(m?[jt]sx?|cjs)$/.test(f)) {
|
|
289
|
+
const clients = new Set(moduleClients ??= httpClients(pj, m));
|
|
290
|
+
for (const x of text.matchAll(/(\w+)\s*=\s*(?:express|fastify|Fastify|Router|express\.Router)\(|(\w+)\s*=\s*new\s+Hono\b/g)) clients.delete(x[1] ?? x[2]);
|
|
291
|
+
// Express routers: a route on a router is served under the path it is mounted at. Mounted in this file, the
|
|
292
|
+
// prefix is known; mounted elsewhere it is not, and the route is listed but never matched.
|
|
293
|
+
const routers = new Set([...text.matchAll(/(\w+)\s*=\s*(?:express\.)?Router\(/g)].map((x) => x[1]));
|
|
294
|
+
const mountedHere = new Map();
|
|
295
|
+
for (const x of text.matchAll(/\b(\w+)\.use\(\s*(['"`])(\/[^'"`]*)\2\s*,\s*(\w+)/g)) {
|
|
296
|
+
if (routers.has(x[4])) mountedHere.set(x[4], x[3]);
|
|
297
|
+
else mounts.push(normPath(x[3]).path);
|
|
298
|
+
}
|
|
299
|
+
let nest = null;
|
|
300
|
+
L.forEach((l, i) => {
|
|
301
|
+
if (isComment(l)) return;
|
|
302
|
+
const dyn = /\b(app|router|server|fastify|api|routes)\.(get|post|put|patch|delete|all)\(\s*[A-Za-z_$][\w.$]*\s*,/.exec(l);
|
|
303
|
+
if (dyn && !clients.has(dyn[1])) dynamic += 1;
|
|
304
|
+
const ex = /\b(app|router|server|fastify|api|routes)\.(get|post|put|patch|delete|all)\(\s*(['"`])(\/[^'"`]*)\3/.exec(l);
|
|
305
|
+
if (ex && !clients.has(ex[1])) {
|
|
306
|
+
const onRouter = routers.has(ex[1]) || ex[1] === 'router' || ex[1] === 'routes';
|
|
307
|
+
const pre = onRouter ? mountedHere.get(ex[1]) : '';
|
|
308
|
+
add(provides, m, ex[2] === 'all' ? '*' : ex[2], onRouter && pre !== undefined ? join(pre, ex[4]) : ex[4], f, i, ex[4], onRouter && pre === undefined);
|
|
309
|
+
}
|
|
310
|
+
const nc = /@Controller\(\s*['"]([^'"]*)['"]\s*\)/.exec(l); if (nc) nest = nc[1];
|
|
311
|
+
const nm = /@(Get|Post|Put|Patch|Delete|All)\(\s*(?:['"]([^'"]*)['"])?\s*\)/.exec(l);
|
|
312
|
+
if (nm && nest !== null) add(provides, m, nm[1] === 'All' ? '*' : nm[1], join(nest, nm[2] || ''), f, i, `@${nm[1]}`);
|
|
313
|
+
const fe = /\bfetch\(\s*(['"`])([^'"`]+)\1/.exec(l);
|
|
314
|
+
if (fe && !/^https?:/.test(fe[2])) {
|
|
315
|
+
const win = L.slice(i, i + 4).join(' ');
|
|
316
|
+
const mt = /method\s*:\s*['"](\w+)['"]/.exec(win);
|
|
317
|
+
add(consumes, m, mt ? mt[1] : 'GET', fe[2], f, i, fe[2]);
|
|
318
|
+
}
|
|
319
|
+
// `api.get<User[]>("/x")` and a call whose path is on the next line read alike.
|
|
320
|
+
const ax = /\b(\w+)\.(get|post|put|patch|delete)(?:<[^()]*?>)?\(/.exec(l);
|
|
321
|
+
const axa = ax && clients.has(ax[1]) ? firstArg(L, i, ax.index + ax[0].length) : null;
|
|
322
|
+
if (axa && axa.s && !/^https?:/.test(axa.s)) add(consumes, m, ax[2], axa.s, f, axa.at, axa.s);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
// Python: FastAPI / Flask / Django providers; requests / httpx consumers
|
|
326
|
+
if (/\.py$/.test(f)) {
|
|
327
|
+
const pre = new Map();
|
|
328
|
+
for (const x of text.matchAll(/(\w+)\s*=\s*(?:APIRouter|Blueprint)\([^)]*?(?:prefix|url_prefix)\s*=\s*['"]([^'"]+)['"]/g)) pre.set(x[1], x[2]);
|
|
329
|
+
L.forEach((l, i) => {
|
|
330
|
+
if (isComment(l)) return;
|
|
331
|
+
// A decorator's path on its own line, as black writes a long one, reads like one on the decorator's.
|
|
332
|
+
const fa = /^\s*@(\w+)\.(get|post|put|patch|delete)\(/.exec(l);
|
|
333
|
+
const faa = fa && firstArg(L, i, fa[0].length);
|
|
334
|
+
if (faa) add(provides, m, fa[2], join(pre.get(fa[1]) || '', faa.s), f, faa.s ? faa.at : i, faa.s);
|
|
335
|
+
const flo = /^\s*@(\w+)\.route\(/.exec(l);
|
|
336
|
+
const fla = flo && firstArg(L, i, flo[0].length);
|
|
337
|
+
if (fla) {
|
|
338
|
+
const fl = [null, flo[1], fla.s];
|
|
339
|
+
const ms = /methods\s*=\s*\[([^\]]*)\]/.exec(L.slice(i, fla.at + 4).join(' '));
|
|
340
|
+
const methods = ms ? [...ms[1].matchAll(/['"](\w+)['"]/g)].map((x) => x[1]) : ['GET'];
|
|
341
|
+
for (const mt of methods) add(provides, m, mt, join(pre.get(fl[1]) || '', fl[2]), f, fl[2] ? fla.at : i, fl[2]);
|
|
342
|
+
}
|
|
343
|
+
if (/(^|\/)urls\.py$/.test(f)) {
|
|
344
|
+
const dj = /\b(?:re_)?path\(\s*r?['"]([^'"]*)['"]\s*,\s*(?!include\()/.exec(l);
|
|
345
|
+
if (dj) add(provides, m, '*', dj[1], f, i, dj[1]);
|
|
346
|
+
}
|
|
347
|
+
const rq = /\b(requests|httpx|client|session)\.(get|post|put|patch|delete)\(\s*f?['"]([^'"]+)['"]/.exec(l);
|
|
348
|
+
if (rq && !/^https?:/.test(rq[3])) add(consumes, m, rq[2], rq[3], f, i, rq[3]);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
// Go: net/http, gin, chi providers; http.Get / NewRequest consumers
|
|
352
|
+
if (/\.go$/.test(f)) {
|
|
353
|
+
const pre = new Map();
|
|
354
|
+
L.forEach((l, i) => {
|
|
355
|
+
if (isComment(l)) return;
|
|
356
|
+
const gr = /(\w+)\s*:?=\s*(\w+)\.Group\(\s*"([^"]*)"/.exec(l); if (gr) pre.set(gr[1], join(pre.get(gr[2]) || '', gr[3]));
|
|
357
|
+
const hf = /\b(\w+)\.HandleFunc\(\s*"(?:(GET|POST|PUT|PATCH|DELETE)\s+)?(\/[^"]*)"/.exec(l);
|
|
358
|
+
if (hf) add(provides, m, hf[2] || '*', join(pre.get(hf[1]) || '', hf[3]), f, i, hf[3]);
|
|
359
|
+
const gn = /\b(\w+)\.(GET|POST|PUT|PATCH|DELETE|Get|Post|Put|Patch|Delete)\(\s*"(\/[^"]*)"/.exec(l);
|
|
360
|
+
if (gn && gn[1] !== 'http') add(provides, m, gn[2], join(pre.get(gn[1]) || '', gn[3]), f, i, gn[3]);
|
|
361
|
+
const hg = /\bhttp\.(Get|Post|Head)\(\s*"(\/[^"]*)"/.exec(l); if (hg) add(consumes, m, hg[1], hg[2], f, i, hg[2]);
|
|
362
|
+
const nr = /http\.NewRequest(?:WithContext)?\([^"]*"(GET|POST|PUT|PATCH|DELETE)"\s*,\s*"(\/[^"]*)"/.exec(l); if (nr) add(consumes, m, nr[1], nr[2], f, i, nr[2]);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
// Spring (Java/Kotlin). A @FeignClient interface declares calls, so its mappings are consumed.
|
|
366
|
+
if (/\.(java|kt)$/.test(f)) {
|
|
367
|
+
const feign = /@FeignClient\b/.test(text);
|
|
368
|
+
let base = null; let seenClass = false;
|
|
369
|
+
L.forEach((l, i) => {
|
|
370
|
+
if (isComment(l)) return;
|
|
371
|
+
const rm = /@RequestMapping\(\s*(?:value\s*=\s*|path\s*=\s*)?"([^"]*)"(.*)$/.exec(l);
|
|
372
|
+
if (/\b(class|interface)\s+\w+/.test(l)) seenClass = true;
|
|
373
|
+
if (rm && !seenClass) { base = rm[1]; return; }
|
|
374
|
+
const gm = /@(Get|Post|Put|Patch|Delete)Mapping(?:\(\s*(?:value\s*=\s*|path\s*=\s*)?"([^"]*)")?/.exec(l);
|
|
375
|
+
if (gm && seenClass) add(feign ? consumes : provides, m, gm[1], join(base, gm[2] || ''), f, i, `@${gm[1]}Mapping`);
|
|
376
|
+
if (rm && seenClass) { const mt = /RequestMethod\.(\w+)/.exec(rm[2]); add(feign ? consumes : provides, m, mt ? mt[1] : '*', join(base, rm[1]), f, i, '@RequestMapping'); }
|
|
377
|
+
const rt = /\.(getForObject|getForEntity|postForObject|postForEntity|put|delete)\(\s*"(\/[^"]*)"/.exec(l);
|
|
378
|
+
if (rt) add(consumes, m, rt[1].startsWith('get') ? 'GET' : rt[1].startsWith('post') ? 'POST' : rt[1].toUpperCase(), rt[2], f, i, rt[2]);
|
|
379
|
+
const wc = /\.(get|post|put|patch|delete)\(\)\s*\.uri\(\s*"(\/[^"]*)"/.exec(l); if (wc) add(consumes, m, wc[1], wc[2], f, i, wc[2]);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// A route a platform calls rather than code: a compose or Dockerfile healthcheck (`curl -f http://localhost:8000/health`)
|
|
385
|
+
// and a Kubernetes probe (`httpGet: path: /healthz`). Not on the map as a call, since no module makes it; only the
|
|
386
|
+
// provider is spared being reported unused.
|
|
387
|
+
const probes = [];
|
|
388
|
+
for (const f of pj.files) {
|
|
389
|
+
const compose = /(^|\/)(docker-)?compose[^/]*\.ya?ml$/.test(f); const docker = /(^|\/)Dockerfile[^/]*$/.test(f);
|
|
390
|
+
const k8s = /\.ya?ml$/.test(f) && !compose;
|
|
391
|
+
if (!(compose || docker || k8s) || isTestPath(f)) continue;
|
|
392
|
+
const src = pj.read(f); if (!src) continue;
|
|
393
|
+
if (k8s && !/\b(liveness|readiness|startup)Probe\s*:/.test(src.text)) continue;
|
|
394
|
+
src.lines.forEach((l) => {
|
|
395
|
+
if (isComment(l)) return;
|
|
396
|
+
if (k8s) { const kp = /^\s*path\s*:\s*['"]?(\/[^\s'"]*)/.exec(l); if (kp) probes.push(normPath(kp[1]).path); return; }
|
|
397
|
+
for (const x of l.matchAll(/https?:\/\/([^/\s'"]+)(\/[^\s'"\]),;)]*)/g)) if (isLocalHost(x[1])) probes.push(normPath(x[2]).path);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (dynamic) out.notMeasured.push({ what: `routes registered with a run-time path (${dynamic})`, reason: 'the path is a variable, not a literal, so the route is not on the map' });
|
|
401
|
+
return { provides, consumes, mounts: [...new Set(mounts)].sort(), probes: [...new Set(probes)].sort() };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- containers ---------------------------------------------------------------------------------------------
|
|
405
|
+
export function containers(pj, st, rules, hostOf, ex, cfg, out) {
|
|
406
|
+
const services = [];
|
|
407
|
+
for (const f of pj.files.filter((x) => /(^|\/)(docker-)?compose[^/]*\.ya?ml$/.test(x) && !isTestPath(x))) {
|
|
408
|
+
const src = pj.read(f); if (!src) continue;
|
|
409
|
+
let y; try { y = parseYaml(src.text); } catch (e) { out.notMeasured.push({ what: f, reason: `YAML outside the subset this scanner reads: ${e.message}` }); continue; }
|
|
410
|
+
// ${VAR} substitution in a compose file reads the variable.
|
|
411
|
+
src.lines.forEach((l, i) => { for (const x of l.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)/g)) cfg.reads.push({ key: x[1], prefix: false, hasDefault: /:-|-/.test(l.slice(x.index, l.indexOf('}', x.index))), file: f, line: i + 1, ev: ev(f, i + 1, x[1]) }); });
|
|
412
|
+
const svcs = yGet(y, 'services'); if (!svcs || svcs.v.t !== 'map') continue;
|
|
413
|
+
for (const s of svcs.v.e) {
|
|
414
|
+
// One service, however many files define it (docker-compose.yml + docker-compose.override.yml).
|
|
415
|
+
const id = `svc:${s.k}`;
|
|
416
|
+
let node = services.find((x) => x.id === id);
|
|
417
|
+
if (node) node.evidence.push(ev(f, s.line, s.k));
|
|
418
|
+
else { node = { id, kind: 'service', name: s.k, file: f, evidence: [ev(f, s.line, s.k)] }; services.push(node); }
|
|
419
|
+
const img = yGet(s.v, 'image');
|
|
420
|
+
if (img && img.v.t === 'str') {
|
|
421
|
+
const name = img.v.v.replace(/^[^/]+\.[^/]+\//, (r) => (/^(mcr\.microsoft\.com|quay\.io|docker\.elastic\.co)\//.test(r) ? r : '')).split(/[:@]/)[0];
|
|
422
|
+
if (!node.image) node.image = img.v.v;
|
|
423
|
+
const hit = rules.images[name] || rules.images[name.replace(/^library\//, '')];
|
|
424
|
+
if (hit) {
|
|
425
|
+
const n = ex.node(hit[0], hit[1]); const e = ev(f, img.line, 'image'); n.evidence.push(e);
|
|
426
|
+
out.edges.push({ from: id, to: n.id, kind: 'runs', confidence: 'evidence', via: 'compose', evidence: [e] });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const build = yGet(s.v, 'build');
|
|
430
|
+
if (build) {
|
|
431
|
+
const ctx = build.v.t === 'str' ? build.v.v : (yGet(build.v, 'context') || { v: { v: '.' } }).v.v;
|
|
432
|
+
const dir = P.normalize(P.join(dirOf(f), ctx)).replace(/\/$/, '') || '.';
|
|
433
|
+
const t = st.modules.filter((m) => m.dir === dir || under(m.dir, dir)).sort((a, b) => a.dir.length - b.dir.length)[0];
|
|
434
|
+
if (t) { const e = ev(f, build.line, 'build'); out.edges.push({ from: id, to: t.id, kind: 'builds', confidence: 'evidence', via: 'compose', evidence: [e] }); t.deployed = e; }
|
|
435
|
+
}
|
|
436
|
+
for (const k of ['depends_on', 'links']) {
|
|
437
|
+
const d = yGet(s.v, k); if (!d) continue;
|
|
438
|
+
const names = d.v.t === 'seq' ? d.v.e.map((x) => ({ n: x.v.v, line: x.line })) : d.v.t === 'map' ? d.v.e.map((x) => ({ n: x.k, line: x.line })) : [];
|
|
439
|
+
for (const { n, line } of names) {
|
|
440
|
+
const target = String(n).split(':')[0];
|
|
441
|
+
out.edges.push({ from: id, to: `svc:${target}`, kind: 'depends-on', confidence: 'evidence', via: 'compose', evidence: [ev(f, line, target)] });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const env = yGet(s.v, 'environment');
|
|
445
|
+
if (env) {
|
|
446
|
+
const items = env.v.t === 'map' ? env.v.e.map((x) => ({ k: x.k, v: x.v.v, line: x.line }))
|
|
447
|
+
: env.v.t === 'seq' ? env.v.e.map((x) => { const [k, ...r] = String(x.v.v).split('='); return { k, v: r.join('='), line: x.line }; }) : [];
|
|
448
|
+
for (const it of items) cfg.defs.push({ key: it.k, file: f, line: it.line, source: 'compose', ev: ev(f, it.line, it.k) });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const kube = pj.files.filter((f) => /(^|\/)(k8s|kubernetes|manifests|deploy)\/.*\.ya?ml$/.test(f) && !isTestPath(f));
|
|
453
|
+
if (kube.length) out.notMeasured.push({ what: `Kubernetes manifests (${kube.length} file${kube.length === 1 ? '' : 's'})`, reason: 'not parsed: services and resources declared there are not on the map' });
|
|
454
|
+
const helm = pj.files.filter((f) => /(^|\/)Chart\.ya?ml$/.test(f) && !isTestPath(f));
|
|
455
|
+
if (helm.length) out.notMeasured.push({ what: `Helm charts (${helm.length})`, reason: 'templates are not rendered, so nothing in them is on the map' });
|
|
456
|
+
return services;
|
|
457
|
+
}
|