@appport/assessment 0.0.0-stage → 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/CHANGELOG.md +10 -0
- package/README.md +45 -2
- package/dist/analyze.d.ts +40 -0
- package/dist/analyze.d.ts.map +1 -0
- package/dist/analyze.js +932 -0
- package/dist/analyze.js.map +1 -0
- package/dist/artifact.d.ts +363 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +260 -0
- package/dist/artifact.js.map +1 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +162 -0
- package/dist/cli.js.map +1 -0
- package/dist/files.d.ts +53 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +219 -0
- package/dist/files.js.map +1 -0
- package/dist/go.d.ts +103 -0
- package/dist/go.d.ts.map +1 -0
- package/dist/go.js +448 -0
- package/dist/go.js.map +1 -0
- package/dist/http.d.ts +22 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +306 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/js.d.ts +115 -0
- package/dist/js.d.ts.map +1 -0
- package/dist/js.js +681 -0
- package/dist/js.js.map +1 -0
- package/dist/proposal.d.ts +29 -0
- package/dist/proposal.d.ts.map +1 -0
- package/dist/proposal.js +129 -0
- package/dist/proposal.js.map +1 -0
- package/dist/proto.d.ts +69 -0
- package/dist/proto.d.ts.map +1 -0
- package/dist/proto.js +422 -0
- package/dist/proto.js.map +1 -0
- package/package.json +40 -3
- package/schemas/application-assessment-2.schema.json +1467 -0
- package/schemas/application-assessment.schema.json +1424 -0
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existing-application assessment: reads a repository statically and produces an `appport.application-assessment/1`
|
|
3
|
+
* artifact (spec/assessment-1.md). Reads only; never writes, executes or fetches.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { basename, posix } from "node:path";
|
|
7
|
+
import { isValidName } from "@appport/protocol";
|
|
8
|
+
import { ASSESSMENT_FORMAT, ASSESSMENT_FORMAT_2, BLOCKING_CONCERNS, SCANNER_VERSION, SCANNER_VERSION_2, TOOL_NAME, TOOL_VERSION, assessmentDigest, evidenceId } from "./artifact.js";
|
|
9
|
+
import { DEFAULT_EXCLUDED_DIRECTORIES, DEFAULT_MAX_FILE_BYTES, gitIdentity, readRepositoryFile, repositoryRoot, walkRepository } from "./files.js";
|
|
10
|
+
import { SourceSyntaxError, bodyFacts, isGeneratedGo, parseGo, signatureTypes } from "./go.js";
|
|
11
|
+
import { httpCandidates } from "./http.js";
|
|
12
|
+
import { JsSyntaxError, readJs, tokenize } from "./js.js";
|
|
13
|
+
import { messageSchema, proposeCapabilityName } from "./proposal.js";
|
|
14
|
+
import { parseProto } from "./proto.js";
|
|
15
|
+
export const DEFAULTS = {
|
|
16
|
+
maxCallDepth: 12,
|
|
17
|
+
maxReachableFunctions: 400,
|
|
18
|
+
smallSurface: { maxHandlerLines: 80, maxReachableFunctions: 60 }
|
|
19
|
+
};
|
|
20
|
+
/** Source languages the assessment recognizes by extension. Only `go` and `protobuf` are analyzed. */
|
|
21
|
+
const LANGUAGES = {
|
|
22
|
+
".go": "go", ".proto": "protobuf", ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript", ".js": "javascript",
|
|
23
|
+
".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".py": "python", ".java": "java", ".kt": "kotlin", ".rb": "ruby", ".rs": "rust",
|
|
24
|
+
".php": "php", ".cs": "csharp", ".swift": "swift", ".scala": "scala", ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".hpp": "cpp",
|
|
25
|
+
".vue": "vue", ".svelte": "svelte", ".dart": "dart", ".ex": "elixir", ".exs": "elixir"
|
|
26
|
+
};
|
|
27
|
+
const SUPPORTED = new Set(["go", "protobuf"]);
|
|
28
|
+
/** What scanner 2 analyzes, beyond scanner 1's. Only plain `.js`/`.ts` families: JSX and TSX are markup, not route registration. */
|
|
29
|
+
const SUPPORTED_2 = new Set([...SUPPORTED, "javascript", "typescript"]);
|
|
30
|
+
const JS_ROUTING_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
|
|
31
|
+
const NOT_ROUTING_PATH = /(^|\/)(tests?|__tests__|__mocks__|spec|e2e|cypress)\/|\.(test|spec)\.[cm]?[jt]s$|\.d\.[cm]?ts$/;
|
|
32
|
+
/** A file that mentions none of these cannot register a route on a framework this reader knows; it is counted, not analyzed. */
|
|
33
|
+
const ROUTING_HINT = /\b(express|fastify|Fastify|Router|koa|Hono|hono)\b|@nestjs\//;
|
|
34
|
+
/** Scanner 2's limitations: scanner 1's, with the languages entry replaced and the HTTP reader's added. */
|
|
35
|
+
export function limitationsFor(scanner) {
|
|
36
|
+
if (scanner === 1)
|
|
37
|
+
return LIMITATIONS;
|
|
38
|
+
return [
|
|
39
|
+
...LIMITATIONS.map((l) => (l.code === "languages" ? { code: "languages", description: "Go, Protocol Buffers, and HTTP route registrations in JavaScript and TypeScript are analyzed. go.mod and package.json are read as manifests. Other languages, and JSX/TSX markup, are counted and reported, not analyzed. A JavaScript or TypeScript file is read only if it mentions a routing framework this reader knows (Express, Fastify, Koa router, Hono, NestJS); the rest are counted, not analyzed." } : l)),
|
|
40
|
+
{ code: "http-routes", description: "A route is found where it is registered with a literal path on a receiver declared as an Express/Fastify/Koa/Hono app or router, or on a NestJS controller. Routes registered in loops, by helper functions, by reflection or from configuration are found only as far as the registration call itself is literal; a name inside a path stays a placeholder." },
|
|
41
|
+
{ code: "http-handlers", description: "A handler's body is not read. A reference is followed one import to a definition of that name when one is found; a handler built by a factory or re-exported is not located. What a middleware enforces is not established." },
|
|
42
|
+
{ code: "http-effect", description: "The proposed effect of a route follows its HTTP method by convention and is inferred; a handler can break the convention in either direction." }
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
export const LIMITATIONS = [
|
|
46
|
+
{ code: "static-only", description: "Nothing in the target was built, executed or contacted; behavior is described from source text, never observed at run time." },
|
|
47
|
+
{ code: "languages", description: "Only Go and Protocol Buffers are analyzed. go.mod and package.json are read as manifests. Files in other languages are counted and reported, not analyzed." },
|
|
48
|
+
{ code: "generated-code", description: "Files marked `Code generated … DO NOT EDIT.` are skipped. Dispatch from generated stubs to handwritten handlers is not traced; it is inferred from method name and request type." },
|
|
49
|
+
{ code: "middleware", description: "Interceptors, middleware and server options that wrap handlers (authentication, rate limits, logging) are not attributed to candidates." },
|
|
50
|
+
{ code: "dynamic-dispatch", description: "Calls through local variables, closures, function-typed fields, reflection and method values are counted, not followed. Interface calls are attributed to same-name methods, in the interface's package or packages importing it, whose parameter and result types match textually; full method sets are not compared, and implementations elsewhere are not found." },
|
|
51
|
+
{ code: "persistence", description: "Persistence is recognized by SQL string literals that begin with a statement keyword. ORMs, query builders, key-value stores and statements assembled without such a literal are not recognized." },
|
|
52
|
+
{ code: "field-behavior", description: "`google.api.field_behavior` REQUIRED and OUTPUT_ONLY are declarations; whether a handler enforces them is not established." },
|
|
53
|
+
{ code: "names", description: "Names and comments link declarations (RPC to handler, test to method) and nothing more; they are never evidence of what code does." },
|
|
54
|
+
{ code: "ignore-rules", description: "Only the repository's root .gitignore is read, and only a subset of its syntax; patterns outside the subset are reported and not applied." }
|
|
55
|
+
];
|
|
56
|
+
const CODE_PACKAGES = {
|
|
57
|
+
"google.golang.org/grpc/codes": { denial: ["Unauthenticated", "PermissionDenied"], rejection: ["InvalidArgument"] },
|
|
58
|
+
"connectrpc.com/connect": { denial: ["CodeUnauthenticated", "CodePermissionDenied"], rejection: ["CodeInvalidArgument"] },
|
|
59
|
+
"net/http": { denial: ["StatusUnauthorized", "StatusForbidden"], rejection: ["StatusBadRequest", "StatusUnprocessableEntity"] }
|
|
60
|
+
};
|
|
61
|
+
const HTTP_CLIENT = new Set(["Get", "Post", "Head", "PostForm", "NewRequest", "NewRequestWithContext"]);
|
|
62
|
+
const SQL = /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|UPSERT|MERGE|WITH)\s/;
|
|
63
|
+
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
64
|
+
const uniqueSorted = (values) => [...new Set(values)].sort(order);
|
|
65
|
+
const dirOf = (path) => posix.dirname(path);
|
|
66
|
+
/** Assesses the repository at `path`. Throws `RepositoryError` for a path that is not a readable directory. */
|
|
67
|
+
export function assessRepository(path, options = {}) {
|
|
68
|
+
const root = repositoryRoot(path);
|
|
69
|
+
const scanner = options.scanner ?? 1;
|
|
70
|
+
const supported = scanner === 2 ? SUPPORTED_2 : SUPPORTED;
|
|
71
|
+
const configuration = {
|
|
72
|
+
scanner: scanner === 2 ? SCANNER_VERSION_2 : SCANNER_VERSION,
|
|
73
|
+
maxFileBytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
74
|
+
excludedDirectories: [...(options.excludedDirectories ?? DEFAULT_EXCLUDED_DIRECTORIES)].sort(order),
|
|
75
|
+
maxCallDepth: options.maxCallDepth ?? DEFAULTS.maxCallDepth,
|
|
76
|
+
maxReachableFunctions: options.maxReachableFunctions ?? DEFAULTS.maxReachableFunctions,
|
|
77
|
+
smallSurface: { ...(options.smallSurface ?? DEFAULTS.smallSurface) }
|
|
78
|
+
};
|
|
79
|
+
const walk = walkRepository(root, { maxFileBytes: configuration.maxFileBytes, excludedDirectories: configuration.excludedDirectories });
|
|
80
|
+
// ---- evidence ----
|
|
81
|
+
const evidence = new Map();
|
|
82
|
+
const record = (e) => {
|
|
83
|
+
const clean = Object.fromEntries(Object.entries(e).filter(([, v]) => v !== undefined && !(Array.isArray(v) && v.length === 0)));
|
|
84
|
+
const id = evidenceId(clean);
|
|
85
|
+
if (!evidence.has(id))
|
|
86
|
+
evidence.set(id, { id, ...clean });
|
|
87
|
+
return id;
|
|
88
|
+
};
|
|
89
|
+
const at = (path, startLine, endLine = startLine) => ({ path, startLine, endLine });
|
|
90
|
+
const findings = [];
|
|
91
|
+
const skipped = [...walk.skipped];
|
|
92
|
+
const malformed = [];
|
|
93
|
+
const languageCounts = new Map();
|
|
94
|
+
// ---- read sources ----
|
|
95
|
+
const read = (path) => {
|
|
96
|
+
try {
|
|
97
|
+
return readRepositoryFile(root, path);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
skipped.push({ path, reason: "unreadable" });
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const goFiles = [];
|
|
105
|
+
const protoFiles = [];
|
|
106
|
+
const jsFiles = new Map();
|
|
107
|
+
let jsNotRouting = 0;
|
|
108
|
+
const modules = [];
|
|
109
|
+
const manifests = [];
|
|
110
|
+
const integrations = [];
|
|
111
|
+
let analyzed = 0;
|
|
112
|
+
let targetName;
|
|
113
|
+
for (const { path } of walk.files) {
|
|
114
|
+
const name = posix.basename(path);
|
|
115
|
+
const extension = posix.extname(path);
|
|
116
|
+
const language = LANGUAGES[extension];
|
|
117
|
+
if (language)
|
|
118
|
+
languageCounts.set(language, (languageCounts.get(language) ?? 0) + 1);
|
|
119
|
+
if (name === "go.mod") {
|
|
120
|
+
const text = read(path);
|
|
121
|
+
if (text === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
analyzed++;
|
|
124
|
+
const lines = text.split("\n");
|
|
125
|
+
const moduleLine = lines.findIndex((l) => /^module\s+\S+/.test(l));
|
|
126
|
+
if (moduleLine < 0) {
|
|
127
|
+
malformed.push({ path, line: 1, message: "no module directive" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const module = lines[moduleLine].trim().split(/\s+/)[1].replace(/^"|"$/g, "");
|
|
131
|
+
const moduleEvidence = record({ class: "observed", kind: "go.module", location: at(path, moduleLine + 1), subject: module, observation: `go.mod declares module ${module}`, method: "manifest-parse" });
|
|
132
|
+
modules.push({ path, module, dir: dirOf(path) });
|
|
133
|
+
manifests.push({ path, kind: "go.mod", name: module, evidence: [moduleEvidence] });
|
|
134
|
+
if (dirOf(path) === "." && !targetName)
|
|
135
|
+
targetName = { name: module.split("/").pop(), source: "go.mod" };
|
|
136
|
+
let block = false;
|
|
137
|
+
lines.forEach((raw, k) => {
|
|
138
|
+
const l = raw.replace(/\/\/.*$/, "").trim();
|
|
139
|
+
const indirect = raw.includes("// indirect");
|
|
140
|
+
if (/^require\s*\($/.test(l))
|
|
141
|
+
block = true;
|
|
142
|
+
else if (block && l === ")")
|
|
143
|
+
block = false;
|
|
144
|
+
else {
|
|
145
|
+
const m = block ? l.match(/^(\S+)\s+(\S+)$/) : l.match(/^require\s+(\S+)\s+(\S+)$/);
|
|
146
|
+
if (m && !indirect)
|
|
147
|
+
integrations.push({ kind: "module-dependency", subject: `${m[1]}@${m[2]}`, evidence: [record({ class: "observed", kind: "go.require", location: at(path, k + 1), subject: m[1], observation: `go.mod requires ${m[1]} ${m[2]}`, method: "manifest-parse" })] });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (name === "package.json") {
|
|
153
|
+
const text = read(path);
|
|
154
|
+
if (text === undefined)
|
|
155
|
+
continue;
|
|
156
|
+
try {
|
|
157
|
+
const manifest = JSON.parse(text);
|
|
158
|
+
analyzed++;
|
|
159
|
+
const lineNo = text.split("\n").findIndex((l) => /"name"\s*:/.test(l));
|
|
160
|
+
const pkgName = typeof manifest.name === "string" ? manifest.name : undefined;
|
|
161
|
+
const ev = pkgName && lineNo >= 0 ? [record({ class: "observed", kind: "npm.package", location: at(path, lineNo + 1), subject: pkgName, observation: `package.json names the package ${pkgName}`, method: "manifest-parse" })] : [];
|
|
162
|
+
manifests.push({ path, kind: "package.json", ...(pkgName ? { name: pkgName } : {}), evidence: ev });
|
|
163
|
+
if (dirOf(path) === "." && pkgName && !targetName)
|
|
164
|
+
targetName = { name: pkgName, source: "package.json" };
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
malformed.push({ path, line: 1, message: `invalid JSON: ${error.message.split("\n")[0]}` });
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (language === "go") {
|
|
172
|
+
const text = read(path);
|
|
173
|
+
if (text === undefined)
|
|
174
|
+
continue;
|
|
175
|
+
if (isGeneratedGo(text)) {
|
|
176
|
+
skipped.push({ path, reason: "generated" });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
goFiles.push({ path, file: parseGo(text), test: name.endsWith("_test.go") });
|
|
181
|
+
analyzed++;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (!(error instanceof SourceSyntaxError))
|
|
185
|
+
throw error;
|
|
186
|
+
malformed.push({ path, line: error.line, message: error.message });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else if (language === "protobuf") {
|
|
190
|
+
const text = read(path);
|
|
191
|
+
if (text === undefined)
|
|
192
|
+
continue;
|
|
193
|
+
try {
|
|
194
|
+
protoFiles.push({ path, file: parseProto(text) });
|
|
195
|
+
analyzed++;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (!(error instanceof SourceSyntaxError))
|
|
199
|
+
throw error;
|
|
200
|
+
malformed.push({ path, line: error.line, message: error.message });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (scanner === 2 && (language === "javascript" || language === "typescript")) {
|
|
204
|
+
if (!JS_ROUTING_EXTENSIONS.has(extension) || NOT_ROUTING_PATH.test(path)) {
|
|
205
|
+
jsNotRouting++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const text = read(path);
|
|
209
|
+
if (text === undefined)
|
|
210
|
+
continue;
|
|
211
|
+
if (!ROUTING_HINT.test(text)) {
|
|
212
|
+
jsNotRouting++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
jsFiles.set(path, readJs(tokenize(text)));
|
|
217
|
+
analyzed++;
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (!(error instanceof JsSyntaxError))
|
|
221
|
+
throw error;
|
|
222
|
+
malformed.push({ path, line: error.line, message: error.message });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ---- Go index ----
|
|
227
|
+
const packages = new Map();
|
|
228
|
+
const tests = [];
|
|
229
|
+
for (const { path, file, test } of goFiles) {
|
|
230
|
+
if (test) {
|
|
231
|
+
for (const fn of file.functions)
|
|
232
|
+
if (fn.body && /^Test/.test(fn.name) && /testing\.T\b/.test(fn.params))
|
|
233
|
+
tests.push({ path, fn, file, facts: bodyFacts(file.tokens, fn.body) });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const dir = dirOf(path);
|
|
237
|
+
const pkg = packages.get(dir) ?? { dir, name: file.packageName, functions: new Map(), types: new Map(), importers: new Set() };
|
|
238
|
+
packages.set(dir, pkg);
|
|
239
|
+
for (const fn of file.functions) {
|
|
240
|
+
const local = fn.receiverType ? `${fn.receiverType}.${fn.name}` : fn.name;
|
|
241
|
+
const key = `${dir}#${local}`;
|
|
242
|
+
// A duplicate key (build-tagged variants) keeps the first in path order; both are real declarations.
|
|
243
|
+
if (!pkg.functions.has(local))
|
|
244
|
+
pkg.functions.set(local, { key, dir, path, symbol: `${dir}:${local}`, fn, file });
|
|
245
|
+
}
|
|
246
|
+
for (const type of file.types)
|
|
247
|
+
if (!pkg.types.has(type.name))
|
|
248
|
+
pkg.types.set(type.name, { type, path });
|
|
249
|
+
}
|
|
250
|
+
const importDir = (importPath) => {
|
|
251
|
+
for (const m of modules) {
|
|
252
|
+
if (importPath === m.module)
|
|
253
|
+
return m.dir;
|
|
254
|
+
if (importPath.startsWith(`${m.module}/`)) {
|
|
255
|
+
const dir = posix.join(m.dir, importPath.slice(m.module.length + 1));
|
|
256
|
+
return packages.has(dir) ? dir : undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
};
|
|
261
|
+
const importPathOf = (dir) => {
|
|
262
|
+
for (const m of modules) {
|
|
263
|
+
if (dir === m.dir)
|
|
264
|
+
return m.module;
|
|
265
|
+
if (m.dir === ".")
|
|
266
|
+
return `${m.module}/${dir}`;
|
|
267
|
+
if (dir.startsWith(`${m.dir}/`))
|
|
268
|
+
return `${m.module}/${dir.slice(m.dir.length + 1)}`;
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
};
|
|
272
|
+
for (const { path, file, test } of goFiles) {
|
|
273
|
+
if (test)
|
|
274
|
+
continue;
|
|
275
|
+
for (const imp of file.imports) {
|
|
276
|
+
const dir = importDir(imp.path);
|
|
277
|
+
if (dir)
|
|
278
|
+
packages.get(dir).importers.add(dirOf(path));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const functionEvidence = (ref) => record({ class: "observed", kind: "go.function", location: at(ref.path, ref.fn.startLine, ref.fn.endLine), subject: ref.fn.name, observation: `${ref.fn.receiverType ? `method ${ref.fn.receiverType}.` : "function "}${ref.fn.name} is declared`, method: "go-static" });
|
|
282
|
+
const importEvidence = (path, file, importPath) => {
|
|
283
|
+
const imp = file.imports.find((i) => i.path === importPath);
|
|
284
|
+
return record({ class: "observed", kind: "go.import", location: at(path, imp.line), subject: importPath, observation: `imports ${importPath}`, method: "go-static" });
|
|
285
|
+
};
|
|
286
|
+
const methodOn = (dir, type, method) => packages.get(dir)?.functions.get(`${type}.${method}`);
|
|
287
|
+
/**
|
|
288
|
+
* Methods that may implement interface method `iface.method` of package `dir`: same-name methods, in that package or
|
|
289
|
+
* one importing it, whose parameter and result types equal the interface method's once the interface package's
|
|
290
|
+
* qualifier is removed. Full method sets are not compared.
|
|
291
|
+
*/
|
|
292
|
+
const implementations = (dir, iface, method) => {
|
|
293
|
+
const pkg = packages.get(dir);
|
|
294
|
+
const signature = iface.signatures.find((m) => m.name === method);
|
|
295
|
+
if (!signature)
|
|
296
|
+
return [];
|
|
297
|
+
const importPath = importPathOf(dir);
|
|
298
|
+
const want = [signatureTypes(signature.params), signatureTypes(signature.results)].map((t) => t.join(";")).join("->");
|
|
299
|
+
const out = [];
|
|
300
|
+
for (const d of [dir, ...pkg.importers].sort(order)) {
|
|
301
|
+
for (const [local, ref] of packages.get(d)?.functions ?? []) {
|
|
302
|
+
if (!ref.fn.receiverType || ref.fn.receiverType === iface.name || local !== `${ref.fn.receiverType}.${method}`)
|
|
303
|
+
continue;
|
|
304
|
+
const alias = d === dir ? undefined : ref.file.imports.find((i) => i.path === importPath)?.name;
|
|
305
|
+
const qualifier = alias ? new RegExp(`(?<![\\w.])${alias}\\.`, "g") : undefined;
|
|
306
|
+
const strip = (types) => types.map((t) => (qualifier ? t.replace(qualifier, "") : t)).join(";");
|
|
307
|
+
if ([strip(signatureTypes(ref.fn.params)), strip(signatureTypes(ref.fn.results))].join("->") === want)
|
|
308
|
+
out.push(ref);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return [...new Set(out)].sort((a, b) => order(a.key, b.key));
|
|
312
|
+
};
|
|
313
|
+
/** Resolves a type expression as written in `ref`'s file to a package dir and type name. */
|
|
314
|
+
const typeTarget = (ref, typeText) => {
|
|
315
|
+
const t = typeText.replace(/^[*&]+/, "").replace(/\[.*$/, "");
|
|
316
|
+
const parts = t.split(".");
|
|
317
|
+
if (parts.length === 1 && /^\w+$/.test(parts[0]))
|
|
318
|
+
return { dir: ref.dir, type: parts[0] };
|
|
319
|
+
if (parts.length === 2) {
|
|
320
|
+
const imp = ref.file.imports.find((i) => i.name === parts[0]);
|
|
321
|
+
if (!imp)
|
|
322
|
+
return undefined;
|
|
323
|
+
const dir = importDir(imp.path);
|
|
324
|
+
return dir ? { dir, type: parts[1] } : { external: imp.path };
|
|
325
|
+
}
|
|
326
|
+
return undefined;
|
|
327
|
+
};
|
|
328
|
+
const methodOrInterface = (dir, type, method) => {
|
|
329
|
+
const direct = methodOn(dir, type, method);
|
|
330
|
+
if (direct)
|
|
331
|
+
return { kind: "internal", targets: [direct], via: "direct" };
|
|
332
|
+
const decl = packages.get(dir)?.types.get(type);
|
|
333
|
+
if (decl?.type.kind === "interface" && decl.type.methods.includes(method)) {
|
|
334
|
+
const impls = implementations(dir, decl.type, method);
|
|
335
|
+
return impls.length ? { kind: "internal", targets: impls, via: "interface" } : { kind: "unresolved", interface: `${dir}:${type}.${method}` };
|
|
336
|
+
}
|
|
337
|
+
return { kind: "unresolved" };
|
|
338
|
+
};
|
|
339
|
+
const resolve = (ref, chain) => {
|
|
340
|
+
const pkg = packages.get(ref.dir);
|
|
341
|
+
const [head, second, third] = chain;
|
|
342
|
+
if (chain.length === 1) {
|
|
343
|
+
const target = pkg.functions.get(head);
|
|
344
|
+
return target ? { kind: "internal", targets: [target], via: "direct" } : { kind: "unresolved" };
|
|
345
|
+
}
|
|
346
|
+
if (ref.fn.receiverName && head === ref.fn.receiverName && ref.fn.receiverType) {
|
|
347
|
+
const receiver = pkg.types.get(ref.fn.receiverType)?.type;
|
|
348
|
+
if (chain.length === 2) {
|
|
349
|
+
const direct = methodOn(ref.dir, ref.fn.receiverType, second);
|
|
350
|
+
if (direct)
|
|
351
|
+
return { kind: "internal", targets: [direct], via: "direct" };
|
|
352
|
+
// A method promoted from an embedded field.
|
|
353
|
+
for (const field of receiver?.fields.filter((f) => f.embedded) ?? []) {
|
|
354
|
+
const target = typeTarget(ref, field.type);
|
|
355
|
+
if (target && "external" in target)
|
|
356
|
+
continue;
|
|
357
|
+
if (target) {
|
|
358
|
+
const r = methodOrInterface(target.dir, target.type, second);
|
|
359
|
+
if (r.kind === "internal")
|
|
360
|
+
return r;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { kind: "unresolved" };
|
|
364
|
+
}
|
|
365
|
+
if (chain.length === 3) {
|
|
366
|
+
const field = receiver?.fields.find((f) => f.name === second);
|
|
367
|
+
if (!field)
|
|
368
|
+
return { kind: "unresolved" };
|
|
369
|
+
const target = typeTarget(ref, field.type);
|
|
370
|
+
if (!target)
|
|
371
|
+
return { kind: "unresolved" };
|
|
372
|
+
if ("external" in target)
|
|
373
|
+
return { kind: "external", importPath: target.external };
|
|
374
|
+
return methodOrInterface(target.dir, target.type, third);
|
|
375
|
+
}
|
|
376
|
+
return { kind: "unresolved" };
|
|
377
|
+
}
|
|
378
|
+
const imp = ref.file.imports.find((i) => i.name === head);
|
|
379
|
+
if (imp) {
|
|
380
|
+
const dir = importDir(imp.path);
|
|
381
|
+
if (!dir)
|
|
382
|
+
return { kind: "external", importPath: imp.path };
|
|
383
|
+
if (chain.length === 2) {
|
|
384
|
+
const target = packages.get(dir).functions.get(second);
|
|
385
|
+
return target ? { kind: "internal", targets: [target], via: "direct" } : { kind: "unresolved" };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return { kind: "unresolved" };
|
|
389
|
+
};
|
|
390
|
+
// ---- per-function facts (cached) ----
|
|
391
|
+
const factsCache = new Map();
|
|
392
|
+
const bodyCache = new Map();
|
|
393
|
+
const body = (ref) => {
|
|
394
|
+
if (!bodyCache.has(ref.key))
|
|
395
|
+
bodyCache.set(ref.key, ref.fn.body ? bodyFacts(ref.file.tokens, ref.fn.body) : { calls: [], selectors: [], strings: [], goStatements: [] });
|
|
396
|
+
return bodyCache.get(ref.key);
|
|
397
|
+
};
|
|
398
|
+
const owner = (ref) => `${ref.fn.receiverType ? `${ref.fn.receiverType}.` : ""}${ref.fn.name}`;
|
|
399
|
+
const factsOf = (ref) => {
|
|
400
|
+
const cached = factsCache.get(ref.key);
|
|
401
|
+
if (cached)
|
|
402
|
+
return cached;
|
|
403
|
+
const facts = { denials: [], rejections: [], persistence: [], outboundHttp: [], async: [] };
|
|
404
|
+
const b = body(ref);
|
|
405
|
+
for (const s of [...b.selectors, ...b.calls.filter((c) => c.chain.length === 2)]) {
|
|
406
|
+
const imp = ref.file.imports.find((i) => i.name === s.chain[0]);
|
|
407
|
+
const codes = imp && CODE_PACKAGES[imp.path];
|
|
408
|
+
const member = s.chain[1];
|
|
409
|
+
if (codes?.denial.includes(member))
|
|
410
|
+
facts.denials.push(record({ class: "observed", kind: "go.status-code", location: at(ref.path, s.line), subject: member, observation: `${owner(ref)} uses ${imp.path} ${member}, an authentication or authorization failure code`, method: "go-static" }));
|
|
411
|
+
if (codes?.rejection.includes(member))
|
|
412
|
+
facts.rejections.push(record({ class: "observed", kind: "go.status-code", location: at(ref.path, s.line), subject: member, observation: `${owner(ref)} uses ${imp.path} ${member}, an invalid-input failure code`, method: "go-static" }));
|
|
413
|
+
}
|
|
414
|
+
for (const c of b.calls) {
|
|
415
|
+
const imp = ref.file.imports.find((i) => i.name === c.chain[0]);
|
|
416
|
+
if (imp?.path === "net/http" && ((c.chain.length === 2 && HTTP_CLIENT.has(c.chain[1])) || (c.chain.length === 3 && c.chain[1] === "DefaultClient")))
|
|
417
|
+
facts.outboundHttp.push(record({ class: "observed", kind: "go.http-client", location: at(ref.path, c.line), subject: c.chain.at(-1), observation: `${owner(ref)} calls net/http ${c.chain.slice(1).join(".")}`, method: "go-static" }));
|
|
418
|
+
}
|
|
419
|
+
for (const s of b.strings) {
|
|
420
|
+
const m = s.value.match(SQL);
|
|
421
|
+
if (!m)
|
|
422
|
+
continue;
|
|
423
|
+
const keyword = m[1];
|
|
424
|
+
const operation = keyword === "SELECT" ? "read" : keyword === "WITH" ? "unknown" : "write";
|
|
425
|
+
facts.persistence.push({ operation, statement: keyword, evidence: record({ class: "observed", kind: "sql.statement", location: at(ref.path, s.line, s.endLine), subject: keyword, observation: `${owner(ref)} contains a SQL ${keyword} statement literal`, method: "go-static" }) });
|
|
426
|
+
}
|
|
427
|
+
for (const line of b.goStatements)
|
|
428
|
+
facts.async.push(record({ class: "observed", kind: "go.goroutine", location: at(ref.path, line), subject: "go", observation: `${owner(ref)} starts a goroutine`, method: "go-static" }));
|
|
429
|
+
factsCache.set(ref.key, facts);
|
|
430
|
+
return facts;
|
|
431
|
+
};
|
|
432
|
+
// ---- proto index ----
|
|
433
|
+
const index = { messages: new Map(), enums: new Map() };
|
|
434
|
+
const protoPath = new Map();
|
|
435
|
+
for (const { path, file } of protoFiles) {
|
|
436
|
+
for (const m of file.messages)
|
|
437
|
+
if (!index.messages.has(m.fullName))
|
|
438
|
+
(index.messages.set(m.fullName, m), protoPath.set(m.fullName, path));
|
|
439
|
+
for (const e of file.enums)
|
|
440
|
+
if (!index.enums.has(e.fullName))
|
|
441
|
+
(index.enums.set(e.fullName, e), protoPath.set(e.fullName, path));
|
|
442
|
+
}
|
|
443
|
+
const shape = (reference, scope) => {
|
|
444
|
+
const known = (n) => index.messages.has(n) || index.enums.has(n) || n.startsWith("google.protobuf.");
|
|
445
|
+
const parts = scope.split(".");
|
|
446
|
+
let full;
|
|
447
|
+
if (reference.startsWith("."))
|
|
448
|
+
full = reference.slice(1);
|
|
449
|
+
else
|
|
450
|
+
for (let k = parts.length; k >= 0 && !full; k--)
|
|
451
|
+
if (known([...parts.slice(0, k), reference].join(".")))
|
|
452
|
+
full = [...parts.slice(0, k), reference].join(".");
|
|
453
|
+
const message = full ? index.messages.get(full) : undefined;
|
|
454
|
+
if (!full || (!message && !full.startsWith("google.protobuf."))) {
|
|
455
|
+
return { shape: { message: reference, resolved: false, fields: [], unresolvedTypes: [reference], evidence: [] } };
|
|
456
|
+
}
|
|
457
|
+
if (!message) {
|
|
458
|
+
const schema = messageSchema(index, full, "input");
|
|
459
|
+
return { shape: { message: full, resolved: true, fields: [], unresolvedTypes: [], evidence: [] }, schema };
|
|
460
|
+
}
|
|
461
|
+
const path = protoPath.get(full);
|
|
462
|
+
const messageEvidence = record({ class: "observed", kind: "proto.message", location: at(path, message.startLine, message.endLine), subject: message.name, observation: `message ${full} is declared with ${message.fields.length} fields`, method: "proto-static" });
|
|
463
|
+
const fields = message.fields.map((f) => ({
|
|
464
|
+
name: f.name,
|
|
465
|
+
jsonName: f.jsonName,
|
|
466
|
+
type: f.type,
|
|
467
|
+
label: f.label,
|
|
468
|
+
behaviors: f.behaviors,
|
|
469
|
+
...(f.oneof ? { oneof: f.oneof } : {}),
|
|
470
|
+
evidence: [record({ class: "observed", kind: "proto.field", location: at(path, f.line), subject: f.name, observation: `field ${f.name} = ${f.number} of type ${f.type}${f.behaviors.length ? ` declared ${f.behaviors.join(", ")}` : ""}`, method: "proto-static" })]
|
|
471
|
+
}));
|
|
472
|
+
const input = messageSchema(index, full, "input");
|
|
473
|
+
const output = messageSchema(index, full, "output");
|
|
474
|
+
return { shape: { message: full, resolved: true, fields, unresolvedTypes: uniqueSorted([...input.unresolved, ...output.unresolved]), evidence: [messageEvidence] }, schema: input };
|
|
475
|
+
};
|
|
476
|
+
// ---- policy references: string literals naming a full RPC method ----
|
|
477
|
+
const services = protoFiles.flatMap(({ path, file }) => file.services.map((service) => ({ path, service, packageName: file.packageName })));
|
|
478
|
+
const fullMethods = new Set(services.flatMap(({ service }) => service.methods.map((m) => `/${service.fullName}/${m.name}`)));
|
|
479
|
+
const policyRefs = new Map();
|
|
480
|
+
const policyDecls = new Map();
|
|
481
|
+
const tokenOwner = (file, k) => {
|
|
482
|
+
const value = file.values.find((v) => v.range[0] <= k && k <= v.range[1]);
|
|
483
|
+
if (value)
|
|
484
|
+
return `${value.keyword} ${value.name}`;
|
|
485
|
+
const fn = file.functions.find((f) => f.body && f.body[0] <= k && k <= f.body[1]);
|
|
486
|
+
return fn ? `func ${fn.receiverType ? `${fn.receiverType}.` : ""}${fn.name}` : undefined;
|
|
487
|
+
};
|
|
488
|
+
for (const { path, file, test } of goFiles) {
|
|
489
|
+
if (test || !fullMethods.size)
|
|
490
|
+
continue;
|
|
491
|
+
file.tokens.forEach((t, k) => {
|
|
492
|
+
if (t.kind !== "string" || !fullMethods.has(t.value))
|
|
493
|
+
return;
|
|
494
|
+
const decl = tokenOwner(file, k);
|
|
495
|
+
const id = record({ class: "observed", kind: "go.method-reference", location: at(path, t.line), subject: t.value, observation: `the literal ${t.value} appears in ${decl ?? "a top-level declaration"}`, method: "go-static" });
|
|
496
|
+
policyRefs.set(t.value, [...(policyRefs.get(t.value) ?? []), id]);
|
|
497
|
+
if (decl) {
|
|
498
|
+
const key = `${path}#${decl}`;
|
|
499
|
+
const entry = policyDecls.get(key) ?? { path, decl, evidence: [] };
|
|
500
|
+
entry.evidence.push(id);
|
|
501
|
+
policyDecls.set(key, entry);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
// ---- configuration keys ----
|
|
506
|
+
const configuration_ = [];
|
|
507
|
+
const firstString = (tokens, open, skipArgs) => {
|
|
508
|
+
let depth = 0;
|
|
509
|
+
let arg = 0;
|
|
510
|
+
for (let k = open; k < tokens.length; k++) {
|
|
511
|
+
const t = tokens[k];
|
|
512
|
+
if (t.kind === "punct" && ["(", "[", "{"].includes(t.value))
|
|
513
|
+
depth++;
|
|
514
|
+
else if (t.kind === "punct" && [")", "]", "}"].includes(t.value)) {
|
|
515
|
+
if (--depth === 0)
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
else if (depth === 1 && t.kind === "punct" && t.value === ",")
|
|
519
|
+
arg++;
|
|
520
|
+
else if (depth === 1 && arg === skipArgs && t.kind === "string")
|
|
521
|
+
return tokens[k - 1]?.value === "(" || tokens[k - 1]?.value === "," ? t : undefined;
|
|
522
|
+
}
|
|
523
|
+
return undefined;
|
|
524
|
+
};
|
|
525
|
+
for (const { path, file, test } of goFiles) {
|
|
526
|
+
if (test)
|
|
527
|
+
continue;
|
|
528
|
+
const alias = (p) => file.imports.find((i) => i.path === p)?.name;
|
|
529
|
+
const os = alias("os");
|
|
530
|
+
const viper = alias("github.com/spf13/viper");
|
|
531
|
+
const tokens = file.tokens;
|
|
532
|
+
for (let k = 0; k + 3 < tokens.length; k++) {
|
|
533
|
+
const [a, dot, b, open] = [tokens[k], tokens[k + 1], tokens[k + 2], tokens[k + 3]];
|
|
534
|
+
if (dot.value !== "." || b.kind !== "ident" || open.value !== "(")
|
|
535
|
+
continue;
|
|
536
|
+
let source;
|
|
537
|
+
let skip = 0;
|
|
538
|
+
if (os && a.value === os && (b.value === "Getenv" || b.value === "LookupEnv"))
|
|
539
|
+
source = "env";
|
|
540
|
+
else if (viper && a.value === viper && /^(Get\w*|SetDefault|BindEnv|IsSet)$/.test(b.value))
|
|
541
|
+
source = "viper";
|
|
542
|
+
else if (a.value === ")" && /^(Persistent)?Flags$/.test(tokens[k - 2]?.value ?? "") && /^(String|Bool|Int|Int64|Uint|Duration|StringSlice|Float64)(Var)?P?$/.test(b.value)) {
|
|
543
|
+
source = "flag";
|
|
544
|
+
skip = b.value.includes("Var") ? 1 : 0;
|
|
545
|
+
}
|
|
546
|
+
if (!source)
|
|
547
|
+
continue;
|
|
548
|
+
const key = firstString(tokens, k + 3, skip);
|
|
549
|
+
if (!key || !key.value)
|
|
550
|
+
continue;
|
|
551
|
+
configuration_.push({ source, key: key.value, evidence: [record({ class: "observed", kind: `config.${source}`, location: at(path, key.line), subject: key.value, observation: `${source === "env" ? "environment variable" : source === "flag" ? "command-line flag" : "configuration key"} ${key.value} is read`, method: "go-static" })] });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// ---- candidates ----
|
|
555
|
+
const constructs = {
|
|
556
|
+
protoServices: services.length,
|
|
557
|
+
protoMethods: services.reduce((n, s) => n + s.service.methods.length, 0),
|
|
558
|
+
goPackages: packages.size,
|
|
559
|
+
goFunctions: [...packages.values()].reduce((n, p) => n + p.functions.size, 0),
|
|
560
|
+
goTestFunctions: tests.length,
|
|
561
|
+
httpRoutes: services.reduce((n, s) => n + s.service.methods.filter((m) => m.http).length, 0)
|
|
562
|
+
};
|
|
563
|
+
const allMethods = [...packages.values()].flatMap((p) => [...p.functions.values()]).filter((r) => r.fn.receiverType);
|
|
564
|
+
const candidates = [];
|
|
565
|
+
const questions = [];
|
|
566
|
+
let truncatedCount = 0;
|
|
567
|
+
let unlinkedCount = 0;
|
|
568
|
+
for (const { path, service, packageName } of services) {
|
|
569
|
+
for (const method of service.methods) {
|
|
570
|
+
const fullMethod = `/${service.fullName}/${method.name}`;
|
|
571
|
+
const id = `cand-${service.fullName}.${method.name}`;
|
|
572
|
+
const rpcEvidence = record({ class: "observed", kind: "proto.rpc", location: at(path, method.startLine, method.endLine), subject: method.name, observation: `service ${service.fullName} declares rpc ${method.name}(${method.input}) returns (${method.output})`, method: "proto-static" });
|
|
573
|
+
const entryPoints = [{ kind: "proto-rpc", subject: fullMethod, evidence: [rpcEvidence] }];
|
|
574
|
+
if (method.http) {
|
|
575
|
+
entryPoints.push({ kind: "http-binding", subject: `${method.http.method.toUpperCase()} ${method.http.path}`, evidence: [record({ class: "observed", kind: "proto.http-rule", location: at(path, method.http.line), subject: method.http.path || method.http.method, observation: `google.api.http binds rpc ${method.name} to ${method.http.method.toUpperCase()} ${method.http.path}${method.http.body ? ` with body "${method.http.body}"` : ""}`, method: "proto-static" })] });
|
|
576
|
+
}
|
|
577
|
+
const unknowns = [];
|
|
578
|
+
const candidateEvidence = new Set([rpcEvidence]);
|
|
579
|
+
// Implementation: a method with the RPC's name taking the request type.
|
|
580
|
+
const inputSimple = method.input.split(".").pop();
|
|
581
|
+
const named = allMethods.filter((r) => r.fn.name === method.name);
|
|
582
|
+
// The request type as a direct pointer parameter (`*pb.Req`), not as a type argument of a wrapper (`*connect.Request[pb.Req]`).
|
|
583
|
+
const typed = named.filter((r) => new RegExp(`\\*(\\w+\\.)?${inputSimple}\\b(?!\\])`).test(r.fn.params));
|
|
584
|
+
let implementation;
|
|
585
|
+
let handler;
|
|
586
|
+
if (typed.length === 1 || (typed.length === 0 && named.length === 1)) {
|
|
587
|
+
handler = typed[0] ?? named[0];
|
|
588
|
+
const fnEvidence = functionEvidence(handler);
|
|
589
|
+
const link = typed.length === 1 ? "name-and-request-type" : "name-only";
|
|
590
|
+
const linkEvidence = record({
|
|
591
|
+
class: "inferred",
|
|
592
|
+
kind: "link.rpc-handler",
|
|
593
|
+
subject: fullMethod,
|
|
594
|
+
observation: `rpc ${method.name} is implemented by ${handler.symbol}: ${link === "name-and-request-type" ? `it is the only method named ${method.name} that takes ${inputSimple}` : `it is the only method named ${method.name}; its parameters do not mention ${inputSimple}`}`,
|
|
595
|
+
method: "derivation",
|
|
596
|
+
basis: [rpcEvidence, fnEvidence],
|
|
597
|
+
limitations: ["Dispatch through generated service registration is not traced."]
|
|
598
|
+
});
|
|
599
|
+
implementation = { symbol: handler.symbol, location: at(handler.path, handler.fn.startLine, handler.fn.endLine), link, evidence: [fnEvidence, linkEvidence] };
|
|
600
|
+
candidateEvidence.add(linkEvidence);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
unlinkedCount++;
|
|
604
|
+
unknowns.push(record({
|
|
605
|
+
class: "unknown",
|
|
606
|
+
kind: "unknown.implementation",
|
|
607
|
+
subject: fullMethod,
|
|
608
|
+
observation: typed.length > 1 || named.length > 1 ? `${Math.max(typed.length, named.length)} methods named ${method.name} could implement rpc ${method.name}; none was chosen` : `no handwritten Go method named ${method.name} was found`,
|
|
609
|
+
method: "derivation"
|
|
610
|
+
}));
|
|
611
|
+
}
|
|
612
|
+
// Reachability.
|
|
613
|
+
const reachable = [];
|
|
614
|
+
const external = new Map();
|
|
615
|
+
const facts = { denials: [], rejections: [], persistence: [], outboundHttp: [], async: [] };
|
|
616
|
+
const interfaceGaps = new Set();
|
|
617
|
+
let unresolvedCalls = 0;
|
|
618
|
+
let truncated = false;
|
|
619
|
+
if (handler) {
|
|
620
|
+
const seen = new Set([handler.key]);
|
|
621
|
+
const queue = [{ ref: handler, depth: 0 }];
|
|
622
|
+
while (queue.length) {
|
|
623
|
+
const { ref, depth } = queue.shift();
|
|
624
|
+
const f = factsOf(ref);
|
|
625
|
+
facts.denials.push(...f.denials);
|
|
626
|
+
facts.rejections.push(...f.rejections);
|
|
627
|
+
facts.persistence.push(...f.persistence);
|
|
628
|
+
facts.outboundHttp.push(...f.outboundHttp);
|
|
629
|
+
facts.async.push(...f.async);
|
|
630
|
+
for (const call of body(ref).calls) {
|
|
631
|
+
const r = resolve(ref, call.chain);
|
|
632
|
+
if (r.kind === "external") {
|
|
633
|
+
if (!external.has(r.importPath))
|
|
634
|
+
external.set(r.importPath, importEvidence(ref.path, ref.file, r.importPath));
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (r.kind === "unresolved") {
|
|
638
|
+
unresolvedCalls++;
|
|
639
|
+
if (r.interface)
|
|
640
|
+
interfaceGaps.add(r.interface);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
for (const target of r.targets) {
|
|
644
|
+
if (seen.has(target.key))
|
|
645
|
+
continue;
|
|
646
|
+
if (depth + 1 > configuration.maxCallDepth || seen.size >= configuration.maxReachableFunctions) {
|
|
647
|
+
truncated = true;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
seen.add(target.key);
|
|
651
|
+
const site = record({ class: "observed", kind: "go.call", location: at(ref.path, call.line), subject: call.chain.at(-1), observation: `${owner(ref)} calls ${call.chain.join(".")}(…)`, method: "go-static" });
|
|
652
|
+
const decl = functionEvidence(target);
|
|
653
|
+
const link = record({
|
|
654
|
+
class: "inferred",
|
|
655
|
+
kind: r.via === "interface" ? "link.interface-call" : "link.call",
|
|
656
|
+
subject: target.symbol,
|
|
657
|
+
observation: r.via === "interface" ? `${call.chain.join(".")} is an interface call that may dispatch to ${target.symbol}` : `${call.chain.join(".")} resolves to ${target.symbol}`,
|
|
658
|
+
method: "derivation",
|
|
659
|
+
basis: [site, decl]
|
|
660
|
+
});
|
|
661
|
+
reachable.push({ symbol: target.symbol, path: target.path, depth: depth + 1, evidence: [link] });
|
|
662
|
+
queue.push({ ref: target, depth: depth + 1 });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (truncated)
|
|
667
|
+
truncatedCount++;
|
|
668
|
+
if (unresolvedCalls)
|
|
669
|
+
unknowns.push(record({ class: "unknown", kind: "unknown.calls", subject: fullMethod, observation: `${unresolvedCalls} call sites in the implementation and its reachable functions could not be resolved statically`, method: "derivation" }));
|
|
670
|
+
for (const gap of [...interfaceGaps].sort(order))
|
|
671
|
+
unknowns.push(record({ class: "unknown", kind: "unknown.interface-implementation", subject: gap, observation: `no implementation of interface method ${gap} was found in the repository`, method: "derivation" }));
|
|
672
|
+
}
|
|
673
|
+
reachable.sort((a, b) => a.depth - b.depth || order(a.symbol, b.symbol));
|
|
674
|
+
// Inputs and outputs.
|
|
675
|
+
const scope = packageName;
|
|
676
|
+
const input = shape(method.input, scope);
|
|
677
|
+
const output = shape(method.output, scope);
|
|
678
|
+
for (const s of [input.shape, output.shape]) {
|
|
679
|
+
if (!s.resolved)
|
|
680
|
+
unknowns.push(record({ class: "unknown", kind: "unknown.message", subject: s.message, observation: `message ${s.message} is not declared in the analyzed .proto files`, method: "derivation" }));
|
|
681
|
+
for (const t of s.unresolvedTypes)
|
|
682
|
+
if (s.resolved)
|
|
683
|
+
unknowns.push(record({ class: "unknown", kind: "unknown.type", subject: t, observation: `type ${t} used by ${s.message} is not declared in the analyzed .proto files`, method: "derivation" }));
|
|
684
|
+
}
|
|
685
|
+
// Tests that call a method with the RPC's name and construct its request type.
|
|
686
|
+
const related = [];
|
|
687
|
+
for (const test of tests) {
|
|
688
|
+
const call = test.facts.calls.find((c) => c.chain.length >= 2 && c.chain.at(-1) === method.name);
|
|
689
|
+
const request = [...test.facts.selectors, ...test.facts.calls].find((s) => s.chain.length === 2 && s.chain[1] === inputSimple);
|
|
690
|
+
if (!call || !request)
|
|
691
|
+
continue;
|
|
692
|
+
const decl = record({ class: "observed", kind: "go.test-function", location: at(test.path, test.fn.startLine, test.fn.endLine), subject: test.fn.name, observation: `test function ${test.fn.name} is declared`, method: "go-static" });
|
|
693
|
+
const site = record({ class: "observed", kind: "go.call", location: at(test.path, call.line), subject: method.name, observation: `${test.fn.name} calls ${call.chain.join(".")}(…)`, method: "go-static" });
|
|
694
|
+
const link = record({ class: "inferred", kind: "link.test", subject: test.fn.name, observation: `${test.fn.name} exercises rpc ${method.name}: it calls a method of that name and constructs ${inputSimple}`, method: "derivation", basis: [decl, site] });
|
|
695
|
+
related.push({ symbol: test.fn.name, path: test.path, evidence: [link] });
|
|
696
|
+
}
|
|
697
|
+
related.sort((a, b) => order(a.path, b.path) || order(a.symbol, b.symbol));
|
|
698
|
+
const denials = uniqueSorted(facts.denials);
|
|
699
|
+
const rejections = uniqueSorted(facts.rejections);
|
|
700
|
+
const persistence = [...new Map(facts.persistence.map((p) => [p.evidence, p])).values()].sort((a, b) => order(a.evidence, b.evidence)).map((p) => ({ operation: p.operation, statement: p.statement, evidence: [p.evidence] }));
|
|
701
|
+
const outboundHttp = uniqueSorted(facts.outboundHttp);
|
|
702
|
+
const async = uniqueSorted(facts.async);
|
|
703
|
+
const policyReferences = uniqueSorted(policyRefs.get(fullMethod) ?? []);
|
|
704
|
+
const permissions = record({ class: "unknown", kind: "unknown.permissions", subject: fullMethod, observation: `the permissions a caller of ${method.name} must hold are not established by static reading`, method: "derivation" });
|
|
705
|
+
unknowns.push(permissions);
|
|
706
|
+
// Readiness.
|
|
707
|
+
const handlerLines = handler ? handler.fn.endLine - handler.fn.startLine + 1 : 0;
|
|
708
|
+
const criteria = [
|
|
709
|
+
{ criterion: "entry-point", satisfied: true, reason: method.http ? "rpc-with-http-binding" : "rpc-declared", evidence: entryPoints.flatMap((e) => e.evidence) },
|
|
710
|
+
{ criterion: "implementation", satisfied: implementation?.link === "name-and-request-type", reason: implementation ? `handler-matched-by-${implementation.link}` : "handler-not-found", evidence: implementation?.evidence ?? unknowns.slice(0, 1) },
|
|
711
|
+
{ criterion: "io-schema", satisfied: input.shape.resolved && output.shape.resolved && !input.shape.unresolvedTypes.length && !output.shape.unresolvedTypes.length, reason: input.shape.resolved && output.shape.resolved ? (input.shape.unresolvedTypes.length || output.shape.unresolvedTypes.length ? "nested-types-unresolved" : "messages-resolved") : "message-unresolved", evidence: [...input.shape.evidence, ...output.shape.evidence] },
|
|
712
|
+
{ criterion: "validation", satisfied: rejections.length > 0, reason: rejections.length ? "invalid-input-code-reachable" : "no-invalid-input-code-reachable", evidence: rejections },
|
|
713
|
+
{ criterion: "authorization", satisfied: denials.length > 0, reason: denials.length ? "denial-code-reachable" : "no-denial-code-reachable", evidence: denials },
|
|
714
|
+
{ criterion: "tests", satisfied: related.length > 0, reason: related.length ? "tests-call-handler-with-request-type" : "no-related-tests", evidence: related.flatMap((t) => t.evidence) },
|
|
715
|
+
{ criterion: "reachability-complete", satisfied: Boolean(handler) && !truncated, reason: !handler ? "no-handler" : truncated ? "call-graph-truncated" : "call-graph-within-limits", evidence: implementation?.evidence ?? [] },
|
|
716
|
+
{
|
|
717
|
+
criterion: "small-surface",
|
|
718
|
+
satisfied: Boolean(handler) && handlerLines <= configuration.smallSurface.maxHandlerLines && reachable.length <= configuration.smallSurface.maxReachableFunctions,
|
|
719
|
+
reason: !handler ? "no-handler" : handlerLines > configuration.smallSurface.maxHandlerLines ? "handler-too-long" : reachable.length > configuration.smallSurface.maxReachableFunctions ? "too-many-reachable-functions" : "within-thresholds",
|
|
720
|
+
evidence: implementation?.evidence ?? []
|
|
721
|
+
}
|
|
722
|
+
];
|
|
723
|
+
const writes = persistence.filter((p) => p.operation !== "read");
|
|
724
|
+
const concerns = [];
|
|
725
|
+
if (outboundHttp.length)
|
|
726
|
+
concerns.push({ concern: "outbound-http", evidence: outboundHttp });
|
|
727
|
+
if (async.length)
|
|
728
|
+
concerns.push({ concern: "async-work", evidence: async });
|
|
729
|
+
if (writes.length)
|
|
730
|
+
concerns.push({ concern: "writes-state", evidence: writes.flatMap((w) => w.evidence) });
|
|
731
|
+
if (interfaceGaps.size)
|
|
732
|
+
concerns.push({ concern: "unresolved-interface", evidence: unknowns.filter((u) => evidence.get(u).kind === "unknown.interface-implementation") });
|
|
733
|
+
const all = criteria.every((c) => c.satisfied);
|
|
734
|
+
const blocked = concerns.some((c) => BLOCKING_CONCERNS.includes(c.concern));
|
|
735
|
+
const readinessClass = all && !blocked ? "exposure-candidate" : implementation ? "investigate" : "insufficient-evidence";
|
|
736
|
+
// AppPort exposure proposal.
|
|
737
|
+
const httpWrite = method.http && method.http.method !== "get";
|
|
738
|
+
const effect = writes.length || outboundHttp.length || async.length || httpWrite ? "consequential" : "observation";
|
|
739
|
+
const effectReasons = [
|
|
740
|
+
...(writes.length ? ["sql-write-reachable"] : []),
|
|
741
|
+
...(outboundHttp.length ? ["outbound-http-reachable"] : []),
|
|
742
|
+
...(async.length ? ["goroutine-reachable"] : []),
|
|
743
|
+
...(httpWrite ? [`http-${method.http.method}-binding`] : []),
|
|
744
|
+
...(effect === "observation" ? ["no-write-evidence"] : [])
|
|
745
|
+
];
|
|
746
|
+
const name = proposeCapabilityName(method.name);
|
|
747
|
+
const streaming = method.clientStreaming || method.serverStreaming;
|
|
748
|
+
const exposure = {
|
|
749
|
+
status: "proposed",
|
|
750
|
+
authoritative: false,
|
|
751
|
+
...(name && input.schema && output.schema && isValidName(name)
|
|
752
|
+
? {
|
|
753
|
+
capability: {
|
|
754
|
+
name,
|
|
755
|
+
version: 1,
|
|
756
|
+
kind: method.serverStreaming ? "stream" : "request",
|
|
757
|
+
inputSchema: input.schema.schema,
|
|
758
|
+
outputSchema: messageSchema(index, output.shape.message, "output").schema,
|
|
759
|
+
effect
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
: {}),
|
|
763
|
+
effectBasis: { effect, reasons: effectReasons, evidence: uniqueSorted([...writes.flatMap((w) => w.evidence), ...outboundHttp, ...async, ...(httpWrite ? entryPoints[1].evidence : [])]) },
|
|
764
|
+
missing: ["permissions", "fulfillment-claim", "verification-method", "contract-tests", ...(name ? [] : ["capability-name"]), ...(streaming ? ["streaming-semantics"] : [])]
|
|
765
|
+
};
|
|
766
|
+
for (const ids of [entryPoints.flatMap((e) => e.evidence), implementation?.evidence ?? [], denials, rejections, outboundHttp, async, policyReferences, persistence.flatMap((p) => p.evidence), related.flatMap((t) => t.evidence)])
|
|
767
|
+
for (const e of ids)
|
|
768
|
+
candidateEvidence.add(e);
|
|
769
|
+
const candidate = {
|
|
770
|
+
id,
|
|
771
|
+
name: `${service.name}.${method.name}`,
|
|
772
|
+
description: `rpc ${method.name} of service ${service.fullName}${method.http ? `, bound to ${method.http.method.toUpperCase()} ${method.http.path}` : ""}`,
|
|
773
|
+
operation: { kind: "rpc", service: service.fullName, method: method.name, fullMethod, streaming },
|
|
774
|
+
entryPoints,
|
|
775
|
+
...(implementation ? { implementation } : {}),
|
|
776
|
+
inputs: input.shape,
|
|
777
|
+
outputs: output.shape,
|
|
778
|
+
dependencies: { reachableFunctions: reachable, externalPackages: [...external.entries()].sort(([a], [b]) => order(a, b)).map(([importPath, ev]) => ({ importPath, evidence: [ev] })), unresolvedCalls, truncated },
|
|
779
|
+
sideEffects: { persistence, outboundHttp, async },
|
|
780
|
+
authorization: { denials, policyReferences },
|
|
781
|
+
validation: { rejections },
|
|
782
|
+
tests: related,
|
|
783
|
+
evidence: uniqueSorted(candidateEvidence),
|
|
784
|
+
confidence: implementation?.link === "name-and-request-type" ? "linked" : implementation ? "weakly-linked" : "unlinked",
|
|
785
|
+
readiness: { class: readinessClass, criteria, concerns },
|
|
786
|
+
unknowns: uniqueSorted(unknowns),
|
|
787
|
+
exposure
|
|
788
|
+
};
|
|
789
|
+
candidates.push(candidate);
|
|
790
|
+
if (policyReferences.length)
|
|
791
|
+
questions.push({ id: `q-policy-${id.slice(5)}`, candidate: id, question: `${method.name} is named in a method list (${uniqueSorted(policyReferences.map((e) => evidence.get(e).observation.replace(/^.* appears in /, ""))).join(", ")}). What access does that list grant or deny?`, evidence: policyReferences });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (scanner === 2) {
|
|
795
|
+
const http = httpCandidates({
|
|
796
|
+
files: jsFiles,
|
|
797
|
+
paths: new Set(walk.files.map((f) => f.path)),
|
|
798
|
+
text: (p) => {
|
|
799
|
+
try {
|
|
800
|
+
return readRepositoryFile(root, p);
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
return undefined;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}, { record, at, evidence });
|
|
807
|
+
candidates.push(...http.candidates);
|
|
808
|
+
findings.push(...http.findings);
|
|
809
|
+
constructs.httpRoutes += http.routes;
|
|
810
|
+
unlinkedCount += http.candidates.filter((c) => c.confidence === "unlinked").length;
|
|
811
|
+
}
|
|
812
|
+
candidates.sort((a, b) => order(a.id, b.id));
|
|
813
|
+
// ---- ranking and recommendation ----
|
|
814
|
+
const CLASS_RANK = { "exposure-candidate": 0, investigate: 1, "insufficient-evidence": 2 };
|
|
815
|
+
const RANKING_RULES = ["readiness-class", "more-criteria-satisfied", "fewer-concerns", "observation-before-consequential", "fewer-reachable-functions", "candidate-id"];
|
|
816
|
+
const rankKey = (c) => [CLASS_RANK[c.readiness.class], -c.readiness.criteria.filter((x) => x.satisfied).length, c.readiness.concerns.length, c.exposure.effectBasis.effect === "observation" ? 0 : 1, c.dependencies.reachableFunctions.length];
|
|
817
|
+
const ranked = [...candidates].sort((a, b) => {
|
|
818
|
+
const ka = rankKey(a);
|
|
819
|
+
const kb = rankKey(b);
|
|
820
|
+
for (let k = 0; k < ka.length; k++)
|
|
821
|
+
if (ka[k] !== kb[k])
|
|
822
|
+
return ka[k] - kb[k];
|
|
823
|
+
return order(a.id, b.id);
|
|
824
|
+
});
|
|
825
|
+
const exposureCandidates = ranked.filter((c) => c.readiness.class === "exposure-candidate");
|
|
826
|
+
let recommendation = null;
|
|
827
|
+
const top = exposureCandidates[0];
|
|
828
|
+
if (top) {
|
|
829
|
+
const beforeExposure = [...top.unknowns];
|
|
830
|
+
const permissionQuestion = { id: `q-permissions-${top.id.slice(5)}`, candidate: top.id, question: `Which permissions must a caller hold to invoke ${top.operation.kind === "rpc" ? top.operation.method : top.name}, and is anonymous access ever allowed?`, evidence: top.authorization.denials };
|
|
831
|
+
questions.push(permissionQuestion);
|
|
832
|
+
beforeExposure.push(permissionQuestion.id, ...questions.filter((q) => q.candidate === top.id && q.id !== permissionQuestion.id).map((q) => q.id));
|
|
833
|
+
recommendation = {
|
|
834
|
+
candidate: top.id,
|
|
835
|
+
readiness: "exposure-candidate",
|
|
836
|
+
reasons: [...top.readiness.criteria.filter((c) => c.satisfied).map((c) => `${c.criterion}:${c.reason}`), `effect:${top.exposure.effectBasis.effect}`, ...top.readiness.concerns.map((c) => `concern:${c.concern}`)],
|
|
837
|
+
ranking: { rules: RANKING_RULES, position: 1, of: exposureCandidates.length, alternatives: exposureCandidates.slice(1, 4).map((c) => c.id) },
|
|
838
|
+
beforeExposure: uniqueSorted(beforeExposure)
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
for (const [, decl] of [...policyDecls.entries()].sort(([a], [b]) => order(a, b)))
|
|
842
|
+
questions.push({ id: `q-policy-list-${decl.path}#${decl.decl.replace(/^\w+ /, "")}`, question: `${decl.decl} in ${decl.path} lists ${decl.evidence.length} RPC methods. What does membership in it mean for authentication and authorization?`, evidence: uniqueSorted(decl.evidence) });
|
|
843
|
+
questions.sort((a, b) => order(a.id, b.id));
|
|
844
|
+
// ---- coverage and findings ----
|
|
845
|
+
const languages = [...languageCounts.entries()].sort(([a], [b]) => order(a, b)).map(([language, files]) => ({ language, files, supported: supported.has(language) }));
|
|
846
|
+
const unsupported = languages.filter((l) => !l.supported);
|
|
847
|
+
skipped.sort((a, b) => order(a.path, b.path) || order(a.reason, b.reason));
|
|
848
|
+
malformed.sort((a, b) => order(a.path, b.path));
|
|
849
|
+
const partialReasons = [
|
|
850
|
+
...(malformed.length ? ["malformed-files"] : []),
|
|
851
|
+
...(skipped.some((s) => s.reason === "unreadable") ? ["unreadable-files"] : []),
|
|
852
|
+
...(skipped.some((s) => s.reason === "too-large") ? ["too-large-files"] : []),
|
|
853
|
+
...(unsupported.length ? ["unsupported-languages"] : []),
|
|
854
|
+
...(jsNotRouting ? ["javascript-files-not-routing-code"] : []),
|
|
855
|
+
...(walk.unsupportedIgnorePatterns.length ? ["unsupported-ignore-patterns"] : []),
|
|
856
|
+
...(truncatedCount ? ["call-graph-truncated"] : [])
|
|
857
|
+
];
|
|
858
|
+
for (const m of malformed)
|
|
859
|
+
findings.push({ code: "FILE_MALFORMED", severity: "warning", message: `${m.path}:${m.line}: ${m.message}; the file was not analyzed`, path: m.path });
|
|
860
|
+
for (const s of skipped.filter((s) => s.reason === "unreadable" || s.reason === "too-large"))
|
|
861
|
+
findings.push({ code: s.reason === "unreadable" ? "FILE_UNREADABLE" : "FILE_TOO_LARGE", severity: "warning", message: `${s.path} was not read (${s.reason})`, path: s.path });
|
|
862
|
+
for (const s of skipped.filter((s) => s.reason === "symlink"))
|
|
863
|
+
findings.push({ code: "SYMLINK_SKIPPED", severity: "info", message: `${s.path} is a symbolic link and was not followed`, path: s.path });
|
|
864
|
+
for (const l of unsupported)
|
|
865
|
+
findings.push({ code: "LANGUAGE_UNSUPPORTED", severity: "info", message: `${l.files} ${l.language} files were counted, not analyzed` });
|
|
866
|
+
for (const p of walk.unsupportedIgnorePatterns)
|
|
867
|
+
findings.push({ code: "IGNORE_PATTERN_UNSUPPORTED", severity: "info", message: `.gitignore pattern ${JSON.stringify(p)} was not applied` });
|
|
868
|
+
if (jsNotRouting)
|
|
869
|
+
findings.push({ code: "JAVASCRIPT_NOT_ANALYZED", severity: "info", message: `${jsNotRouting} JavaScript and TypeScript files were counted, not analyzed: they are markup, tests, declarations, or mention no routing framework this reader knows` });
|
|
870
|
+
if (!candidates.length)
|
|
871
|
+
findings.push({ code: "NO_ENTRY_POINTS", severity: "warning", message: scanner === 2 ? "no supported entry points (Protocol Buffers services, or HTTP routes in JavaScript or TypeScript) were found" : "no supported entry points (Protocol Buffers services) were found" });
|
|
872
|
+
if (unlinkedCount)
|
|
873
|
+
findings.push({ code: "IMPLEMENTATION_UNLINKED", severity: "info", message: `${unlinkedCount} of ${candidates.length} candidates have no linked implementation` });
|
|
874
|
+
if (truncatedCount)
|
|
875
|
+
findings.push({ code: "CALL_GRAPH_TRUNCATED", severity: "info", message: `${truncatedCount} candidates reached the call-graph limits (depth ${configuration.maxCallDepth}, ${configuration.maxReachableFunctions} functions)` });
|
|
876
|
+
if (candidates.length && !top)
|
|
877
|
+
findings.push({ code: "NO_EXPOSURE_CANDIDATE", severity: "warning", message: "no candidate satisfies every readiness criterion; nothing is recommended" });
|
|
878
|
+
const identity = gitIdentity(root);
|
|
879
|
+
if (!identity.revision)
|
|
880
|
+
findings.push({ code: "REVISION_UNKNOWN", severity: "info", message: "no git revision could be read; the assessment is not tied to a revision" });
|
|
881
|
+
findings.sort((a, b) => order(a.code, b.code) || order(a.path ?? "", b.path ?? "") || order(a.message, b.message));
|
|
882
|
+
const coverage = {
|
|
883
|
+
filesSeen: walk.files.length,
|
|
884
|
+
filesAnalyzed: analyzed,
|
|
885
|
+
languages,
|
|
886
|
+
skipped,
|
|
887
|
+
malformed,
|
|
888
|
+
unsupportedIgnorePatterns: walk.unsupportedIgnorePatterns,
|
|
889
|
+
partial: partialReasons.length > 0,
|
|
890
|
+
partialReasons
|
|
891
|
+
};
|
|
892
|
+
const inventory = {
|
|
893
|
+
manifests: manifests.sort((a, b) => order(a.path, b.path)),
|
|
894
|
+
constructs,
|
|
895
|
+
entryPoints: candidates.flatMap((c) => c.entryPoints.map((e) => ({ candidate: c.id, kind: e.kind, subject: e.subject, evidence: e.evidence }))),
|
|
896
|
+
persistence: uniqueSorted(candidates.flatMap((c) => c.sideEffects.persistence.flatMap((p) => p.evidence))).map((id) => {
|
|
897
|
+
const e = evidence.get(id);
|
|
898
|
+
const keyword = e.subject;
|
|
899
|
+
return { operation: keyword === "SELECT" ? "read" : keyword === "WITH" ? "unknown" : "write", path: e.location.path, evidence: [id] };
|
|
900
|
+
}),
|
|
901
|
+
integrations: [
|
|
902
|
+
...integrations,
|
|
903
|
+
...uniqueSorted(candidates.flatMap((c) => c.sideEffects.outboundHttp)).map((id) => ({ kind: "outbound-http", subject: `${evidence.get(id).location.path}:${evidence.get(id).location.startLine}`, evidence: [id] }))
|
|
904
|
+
].sort((a, b) => order(a.kind, b.kind) || order(a.subject, b.subject)),
|
|
905
|
+
configuration: [...new Map(configuration_.map((c) => [`${c.source}:${c.key}:${c.evidence[0]}`, c])).values()].sort((a, b) => order(a.source, b.source) || order(a.key, b.key) || order(a.evidence[0], b.evidence[0])),
|
|
906
|
+
authorization: [
|
|
907
|
+
...[...policyRefs.entries()].map(([subject, ids]) => ({ kind: "policy-reference", subject, evidence: uniqueSorted(ids) })),
|
|
908
|
+
...uniqueSorted(candidates.flatMap((c) => c.authorization.denials)).map((id) => ({ kind: "denial", subject: `${evidence.get(id).location.path}:${evidence.get(id).location.startLine}`, evidence: [id] }))
|
|
909
|
+
].sort((a, b) => order(a.kind, b.kind) || order(a.subject, b.subject)),
|
|
910
|
+
tests: { files: new Set(tests.map((t) => t.path)).size, functions: tests.length }
|
|
911
|
+
};
|
|
912
|
+
const name = targetName ?? { name: basename(root), source: "directory" };
|
|
913
|
+
const sortedEvidence = [...evidence.values()].sort((a, b) => order(a.location?.path ?? "~", b.location?.path ?? "~") || (a.location?.startLine ?? 0) - (b.location?.startLine ?? 0) || order(a.id, b.id));
|
|
914
|
+
const artifact = {
|
|
915
|
+
format: scanner === 2 ? ASSESSMENT_FORMAT_2 : ASSESSMENT_FORMAT,
|
|
916
|
+
assessment: { id: (options.id ?? randomUUID)(), createdAt: (options.now?.() ?? new Date()).toISOString() },
|
|
917
|
+
tool: { name: TOOL_NAME, version: TOOL_VERSION },
|
|
918
|
+
target: { name: name.name, nameSource: name.source, ...identity },
|
|
919
|
+
configuration,
|
|
920
|
+
coverage,
|
|
921
|
+
evidence: sortedEvidence,
|
|
922
|
+
inventory,
|
|
923
|
+
candidates,
|
|
924
|
+
recommendation,
|
|
925
|
+
findings,
|
|
926
|
+
limitations: [...limitationsFor(scanner)],
|
|
927
|
+
questions,
|
|
928
|
+
scope: { grantsAuthority: false, verifiesBehavior: false, modifiesTarget: false }
|
|
929
|
+
};
|
|
930
|
+
return { ...artifact, digest: assessmentDigest(artifact) };
|
|
931
|
+
}
|
|
932
|
+
//# sourceMappingURL=analyze.js.map
|