@adversarylabs/sdk 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +549 -0
- package/dist/index.d.ts +274 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1305 -0
- package/dist/index.js.map +1 -0
- package/package.json +34 -0
- package/schemas/adversary.findings.v1.schema.json +80 -0
- package/schemas/adversary.input.v1.schema.json +21 -0
- package/templates/basic/Dockerfile +9 -0
- package/templates/basic/README.md +12 -0
- package/templates/basic/adversary.yaml +27 -0
- package/templates/basic/package.json +19 -0
- package/templates/basic/src/index.ts +25 -0
- package/templates/basic/test/index.test.ts +21 -0
- package/templates/basic/tsconfig.json +15 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
export const DEFAULT_INPUT_PATH = "/adversary/input.json";
|
|
4
|
+
export const DEFAULT_OUTPUT_PATH = "/adversary/output.json";
|
|
5
|
+
export const ADVERSARY_RUN_PROTOCOL_VERSION = 1;
|
|
6
|
+
const verboseValues = new Set(["1", "true", "TRUE", "yes", "YES"]);
|
|
7
|
+
export const Severity = {
|
|
8
|
+
Info: "info",
|
|
9
|
+
Low: "low",
|
|
10
|
+
Medium: "medium",
|
|
11
|
+
High: "high",
|
|
12
|
+
Critical: "critical",
|
|
13
|
+
};
|
|
14
|
+
export const DEFAULT_SEVERITY_GUIDANCE = [
|
|
15
|
+
{
|
|
16
|
+
severity: Severity.Info,
|
|
17
|
+
guidance: "Interesting observations.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
severity: Severity.Low,
|
|
21
|
+
guidance: "Reasonable engineering improvements.",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
severity: Severity.Medium,
|
|
25
|
+
guidance: "Issues likely to create operational problems.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
severity: Severity.High,
|
|
29
|
+
guidance: "Security, correctness, or reliability risks.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
severity: Severity.Critical,
|
|
33
|
+
guidance: "Immediate production risk.",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
export const Confidence = {
|
|
37
|
+
Low: "low",
|
|
38
|
+
Medium: "medium",
|
|
39
|
+
High: "high",
|
|
40
|
+
};
|
|
41
|
+
export const DEFAULT_CONFIDENCE_THRESHOLDS = {
|
|
42
|
+
medium: 0.6,
|
|
43
|
+
high: 0.85,
|
|
44
|
+
};
|
|
45
|
+
export class RuleRegistry {
|
|
46
|
+
rules = new Map();
|
|
47
|
+
register(rule) {
|
|
48
|
+
assertRuleDefinition(rule);
|
|
49
|
+
this.rules.set(rule.id, rule);
|
|
50
|
+
}
|
|
51
|
+
lookup(ruleId) {
|
|
52
|
+
return this.rules.get(ruleId);
|
|
53
|
+
}
|
|
54
|
+
has(ruleId) {
|
|
55
|
+
return this.rules.has(ruleId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export const ruleRegistry = new RuleRegistry();
|
|
59
|
+
export function defineRule(rule) {
|
|
60
|
+
ruleRegistry.register(rule);
|
|
61
|
+
}
|
|
62
|
+
export const log = {
|
|
63
|
+
debug(message) {
|
|
64
|
+
if (isVerbose()) {
|
|
65
|
+
writeLog("debug", message);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
info(message) {
|
|
69
|
+
if (isVerbose()) {
|
|
70
|
+
writeLog("info", message);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
warn(message) {
|
|
74
|
+
writeLog("warn", message);
|
|
75
|
+
},
|
|
76
|
+
error(message) {
|
|
77
|
+
writeLog("error", message);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
export class Adversary {
|
|
81
|
+
name;
|
|
82
|
+
version;
|
|
83
|
+
rules = [];
|
|
84
|
+
reviewPolicy;
|
|
85
|
+
constructor(options) {
|
|
86
|
+
if (options.name.length === 0) {
|
|
87
|
+
throw new Error("Adversary name must be a non-empty string.");
|
|
88
|
+
}
|
|
89
|
+
this.name = options.name;
|
|
90
|
+
this.version = options.version;
|
|
91
|
+
this.reviewPolicy = options.review ?? {};
|
|
92
|
+
}
|
|
93
|
+
rule(id, handler) {
|
|
94
|
+
if (id.length === 0) {
|
|
95
|
+
throw new Error("Rule id must be a non-empty string.");
|
|
96
|
+
}
|
|
97
|
+
this.rules.push({ id, handler });
|
|
98
|
+
}
|
|
99
|
+
async run(options = {}) {
|
|
100
|
+
const startedAt = performance.now();
|
|
101
|
+
const input = options.input ?? (await parseInput(options.inputPath));
|
|
102
|
+
const repoPath = process.env.ADVERSARY_REPO ?? input.source.path;
|
|
103
|
+
const summary = {};
|
|
104
|
+
const cache = new Map();
|
|
105
|
+
const collector = createReviewCollector();
|
|
106
|
+
const context = createRuleContext(repoPath, summary, cache, collector);
|
|
107
|
+
const includeSuppressed = options.includeSuppressed ?? parseBooleanEnv(process.env.ADVERSARY_INCLUDE_SUPPRESSED);
|
|
108
|
+
for (const rule of this.rules) {
|
|
109
|
+
log.debug(`running rule ${rule.id}`);
|
|
110
|
+
await rule.handler(context);
|
|
111
|
+
}
|
|
112
|
+
const output = buildReviewResult({
|
|
113
|
+
adversary: { name: this.name, version: this.version },
|
|
114
|
+
repository: repoPath,
|
|
115
|
+
filesScanned: typeof summary.files_scanned === "number" ? summary.files_scanned : undefined,
|
|
116
|
+
collector,
|
|
117
|
+
policy: { ...this.reviewPolicy, ...options.review },
|
|
118
|
+
includeSuppressed,
|
|
119
|
+
includeRawObservations: options.includeRawObservations,
|
|
120
|
+
timing: { totalMs: Math.round(performance.now() - startedAt) },
|
|
121
|
+
});
|
|
122
|
+
if (options.write !== false) {
|
|
123
|
+
await writeOutput(createAdversaryRunEnvelope(output), options.outputPath);
|
|
124
|
+
}
|
|
125
|
+
return output;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function createAdversaryRunEnvelope(result) {
|
|
129
|
+
return {
|
|
130
|
+
protocolVersion: ADVERSARY_RUN_PROTOCOL_VERSION,
|
|
131
|
+
result,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export async function parseInput(path = process.env.ADVERSARY_INPUT ?? DEFAULT_INPUT_PATH) {
|
|
135
|
+
const raw = await readFile(path, "utf8");
|
|
136
|
+
const parsed = JSON.parse(raw);
|
|
137
|
+
if (!isRecord(parsed)) {
|
|
138
|
+
throw new Error(`Invalid input at ${path}: expected an object.`);
|
|
139
|
+
}
|
|
140
|
+
if (!isRecord(parsed.source)) {
|
|
141
|
+
throw new Error(`Invalid input at ${path}: source must be an object.`);
|
|
142
|
+
}
|
|
143
|
+
if (typeof parsed.source.path !== "string" || parsed.source.path.length === 0) {
|
|
144
|
+
throw new Error(`Invalid input at ${path}: source.path must be a non-empty string.`);
|
|
145
|
+
}
|
|
146
|
+
return parsed;
|
|
147
|
+
}
|
|
148
|
+
export async function writeOutput(output, path = process.env.ADVERSARY_OUTPUT ?? DEFAULT_OUTPUT_PATH) {
|
|
149
|
+
await mkdir(dirname(path), { recursive: true });
|
|
150
|
+
await writeFile(path, `${JSON.stringify(output, null, 2)}\n`, "utf8");
|
|
151
|
+
}
|
|
152
|
+
export function normalizeConfidence(confidence, thresholds = DEFAULT_CONFIDENCE_THRESHOLDS) {
|
|
153
|
+
if (isConfidence(confidence)) {
|
|
154
|
+
return confidence;
|
|
155
|
+
}
|
|
156
|
+
if (typeof confidence !== "number" ||
|
|
157
|
+
Number.isNaN(confidence) ||
|
|
158
|
+
confidence < 0 ||
|
|
159
|
+
confidence > 1) {
|
|
160
|
+
throw new Error("confidence must be low, medium, high, or a number from 0 to 1.");
|
|
161
|
+
}
|
|
162
|
+
if (confidence >= thresholds.high) {
|
|
163
|
+
return Confidence.High;
|
|
164
|
+
}
|
|
165
|
+
if (confidence >= thresholds.medium) {
|
|
166
|
+
return Confidence.Medium;
|
|
167
|
+
}
|
|
168
|
+
return Confidence.Low;
|
|
169
|
+
}
|
|
170
|
+
export function rankFindings(findings) {
|
|
171
|
+
return [...findings].sort((left, right) => {
|
|
172
|
+
const scoreComparison = scoreFinding(right) - scoreFinding(left);
|
|
173
|
+
if (scoreComparison !== 0) {
|
|
174
|
+
return scoreComparison;
|
|
175
|
+
}
|
|
176
|
+
const severityComparison = severityWeight(right.severity) - severityWeight(left.severity);
|
|
177
|
+
if (severityComparison !== 0) {
|
|
178
|
+
return severityComparison;
|
|
179
|
+
}
|
|
180
|
+
const titleComparison = compareStrings(left.title, right.title);
|
|
181
|
+
if (titleComparison !== 0) {
|
|
182
|
+
return titleComparison;
|
|
183
|
+
}
|
|
184
|
+
return compareStrings(left.id, right.id);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
export class JsonRenderer {
|
|
188
|
+
write;
|
|
189
|
+
constructor(write = (text) => process.stdout.write(text)) {
|
|
190
|
+
this.write = write;
|
|
191
|
+
}
|
|
192
|
+
render(result) {
|
|
193
|
+
this.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export class TerminalRenderer {
|
|
197
|
+
write;
|
|
198
|
+
constructor(write = (text) => process.stdout.write(text)) {
|
|
199
|
+
this.write = write;
|
|
200
|
+
}
|
|
201
|
+
render(result) {
|
|
202
|
+
const lines = [];
|
|
203
|
+
lines.push(`Adversary: ${result.adversary.name}`);
|
|
204
|
+
if (result.target.repository !== undefined) {
|
|
205
|
+
lines.push(`Repository: ${result.target.repository}`);
|
|
206
|
+
}
|
|
207
|
+
lines.push("");
|
|
208
|
+
if (result.assessment !== undefined) {
|
|
209
|
+
lines.push("Overall assessment", "");
|
|
210
|
+
lines.push(`Risk: ${capitalize(result.assessment.risk)}`, "");
|
|
211
|
+
if (result.assessment.summary !== undefined) {
|
|
212
|
+
lines.push(normalizeParagraph(result.assessment.summary), "");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (result.scores !== undefined && result.scores.length > 0) {
|
|
216
|
+
lines.push("Scores", "");
|
|
217
|
+
for (const score of result.scores) {
|
|
218
|
+
lines.push(formatScore(score));
|
|
219
|
+
}
|
|
220
|
+
lines.push("");
|
|
221
|
+
}
|
|
222
|
+
if (result.positives.length > 0) {
|
|
223
|
+
lines.push("Positive signals", "");
|
|
224
|
+
for (const positive of result.positives) {
|
|
225
|
+
lines.push(`- ${normalizeParagraph(positive.summary)}`);
|
|
226
|
+
}
|
|
227
|
+
lines.push("");
|
|
228
|
+
}
|
|
229
|
+
if (result.observations.length > 0) {
|
|
230
|
+
lines.push("Additional observations", "");
|
|
231
|
+
for (const observation of result.observations) {
|
|
232
|
+
lines.push(`- ${normalizeParagraph(observation.summary)}`);
|
|
233
|
+
}
|
|
234
|
+
lines.push("");
|
|
235
|
+
}
|
|
236
|
+
const primary = result.findings[0];
|
|
237
|
+
if (primary !== undefined) {
|
|
238
|
+
lines.push("Primary opportunity", "");
|
|
239
|
+
lines.push(`- ${primary.title.endsWith(".") ? primary.title : `${primary.title}.`}`, "");
|
|
240
|
+
}
|
|
241
|
+
if (result.opinion !== undefined) {
|
|
242
|
+
lines.push("Overall opinion", "", normalizeParagraph(result.opinion.summary), "");
|
|
243
|
+
}
|
|
244
|
+
lines.push("Scan complete", "");
|
|
245
|
+
if (result.target.filesScanned !== undefined) {
|
|
246
|
+
lines.push(`Files scanned: ${result.target.filesScanned}`);
|
|
247
|
+
}
|
|
248
|
+
lines.push(`Findings: ${result.findings.length}`, "");
|
|
249
|
+
for (const finding of result.findings) {
|
|
250
|
+
lines.push(`[${finding.severity}] ${finding.title}`);
|
|
251
|
+
const firstEvidence = finding.evidence.find((item) => item.file !== undefined);
|
|
252
|
+
if (firstEvidence?.file !== undefined) {
|
|
253
|
+
lines.push(formatEvidenceLocation(firstEvidence));
|
|
254
|
+
}
|
|
255
|
+
lines.push("");
|
|
256
|
+
lines.push(`Category: ${finding.category}`);
|
|
257
|
+
lines.push(`Confidence: ${finding.confidence}`, "");
|
|
258
|
+
lines.push("Summary", "", finding.summary, "");
|
|
259
|
+
if (finding.whyItMatters !== undefined) {
|
|
260
|
+
lines.push("Why it matters", "", finding.whyItMatters, "");
|
|
261
|
+
}
|
|
262
|
+
if (finding.impact !== undefined) {
|
|
263
|
+
lines.push("Impact", "", finding.impact, "");
|
|
264
|
+
}
|
|
265
|
+
if (finding.evidence.length > 0) {
|
|
266
|
+
lines.push("Evidence", "");
|
|
267
|
+
for (const evidence of finding.evidence) {
|
|
268
|
+
lines.push(...formatEvidenceLines(evidence));
|
|
269
|
+
}
|
|
270
|
+
lines.push("");
|
|
271
|
+
}
|
|
272
|
+
if (finding.recommendation !== undefined) {
|
|
273
|
+
lines.push("Recommendation", "", normalizeParagraph(finding.recommendation), "");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.write(`${lines.join("\n").trimEnd()}\n`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function createRuleContext(repoPath, summary, cache, collector) {
|
|
280
|
+
const absoluteRepoPath = resolve(repoPath);
|
|
281
|
+
return {
|
|
282
|
+
repoPath: absoluteRepoPath,
|
|
283
|
+
summary,
|
|
284
|
+
cache,
|
|
285
|
+
relpath(path) {
|
|
286
|
+
return relative(absoluteRepoPath, isAbsolute(path) ? path : resolve(absoluteRepoPath, path));
|
|
287
|
+
},
|
|
288
|
+
glob(pattern) {
|
|
289
|
+
return findMatchingPaths(absoluteRepoPath, pattern, false);
|
|
290
|
+
},
|
|
291
|
+
rglob(pattern) {
|
|
292
|
+
return findMatchingPaths(absoluteRepoPath, pattern, true);
|
|
293
|
+
},
|
|
294
|
+
observe(observation) {
|
|
295
|
+
assertObservationInit(observation, "ctx.observe");
|
|
296
|
+
collector.observations.push(observation);
|
|
297
|
+
},
|
|
298
|
+
finding(finding) {
|
|
299
|
+
assertFindingInput(finding, "ctx.finding");
|
|
300
|
+
collector.findings.push(normalizeFindingInput(finding));
|
|
301
|
+
},
|
|
302
|
+
review: {
|
|
303
|
+
assessment(assessment) {
|
|
304
|
+
assertAssessment(assessment);
|
|
305
|
+
collector.assessment = assessment;
|
|
306
|
+
},
|
|
307
|
+
positive(note) {
|
|
308
|
+
assertReviewNote(note, "ctx.review.positive");
|
|
309
|
+
collector.positives.push(note);
|
|
310
|
+
},
|
|
311
|
+
observe(note) {
|
|
312
|
+
assertReviewNote(note, "ctx.review.observe");
|
|
313
|
+
collector.reviewObservations.push(note);
|
|
314
|
+
},
|
|
315
|
+
score(score) {
|
|
316
|
+
assertReviewScore(score);
|
|
317
|
+
collector.scores.push(score);
|
|
318
|
+
},
|
|
319
|
+
opinion(opinion) {
|
|
320
|
+
assertOpinion(opinion);
|
|
321
|
+
collector.opinion = opinion;
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
async function findMatchingPaths(repoPath, pattern, recursive) {
|
|
327
|
+
const matcher = globPatternToRegExp(pattern);
|
|
328
|
+
const paths = recursive ? await walk(repoPath) : await listFiles(repoPath);
|
|
329
|
+
return paths
|
|
330
|
+
.map((path) => relative(repoPath, path))
|
|
331
|
+
.filter((path) => {
|
|
332
|
+
const posixPath = toPosixPath(path);
|
|
333
|
+
const candidate = recursive && !pattern.includes("/") ? basename(posixPath) : posixPath;
|
|
334
|
+
return matcher.test(candidate);
|
|
335
|
+
})
|
|
336
|
+
.sort(compareStrings);
|
|
337
|
+
}
|
|
338
|
+
async function listFiles(directory) {
|
|
339
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
340
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => resolve(directory, entry.name));
|
|
341
|
+
}
|
|
342
|
+
async function walk(directory) {
|
|
343
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
344
|
+
const paths = [];
|
|
345
|
+
for (const entry of entries) {
|
|
346
|
+
const path = resolve(directory, entry.name);
|
|
347
|
+
if (entry.isDirectory()) {
|
|
348
|
+
paths.push(...(await walk(path)));
|
|
349
|
+
}
|
|
350
|
+
else if (entry.isFile()) {
|
|
351
|
+
paths.push(path);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return paths;
|
|
355
|
+
}
|
|
356
|
+
function globPatternToRegExp(pattern) {
|
|
357
|
+
const source = toPosixPath(pattern)
|
|
358
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
359
|
+
.replace(/\*\*/g, "\0")
|
|
360
|
+
.replace(/\*/g, "[^/]*")
|
|
361
|
+
.replace(/\?/g, "[^/]")
|
|
362
|
+
.replace(/\0/g, ".*");
|
|
363
|
+
return new RegExp(`^${source}$`);
|
|
364
|
+
}
|
|
365
|
+
function createReviewCollector() {
|
|
366
|
+
return {
|
|
367
|
+
observations: [],
|
|
368
|
+
findings: [],
|
|
369
|
+
positives: [],
|
|
370
|
+
reviewObservations: [],
|
|
371
|
+
scores: [],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function buildReviewResult(input) {
|
|
375
|
+
const thresholds = input.policy.confidenceThresholds ?? DEFAULT_CONFIDENCE_THRESHOLDS;
|
|
376
|
+
const synthesized = synthesizeObservationFindings(input.collector.observations, thresholds);
|
|
377
|
+
const allFindings = deduplicateFindings([...synthesized, ...input.collector.findings]).map((finding) => calibrateFindingSeverity(finding, input.policy));
|
|
378
|
+
const ranked = rankFindings(allFindings);
|
|
379
|
+
const minimumConfidence = input.policy.minimumConfidence ?? Confidence.Medium;
|
|
380
|
+
const includeInformational = input.policy.includeInformational ?? false;
|
|
381
|
+
const maximumFindings = input.policy.maximumFindings ?? Number.POSITIVE_INFINITY;
|
|
382
|
+
const eligible = [];
|
|
383
|
+
const suppressedFindings = [];
|
|
384
|
+
for (const finding of ranked) {
|
|
385
|
+
const suppressed = confidenceWeight(finding.confidence) < confidenceWeight(minimumConfidence) ||
|
|
386
|
+
(!includeInformational && finding.severity === Severity.Info) ||
|
|
387
|
+
eligible.length >= maximumFindings;
|
|
388
|
+
if (suppressed) {
|
|
389
|
+
suppressedFindings.push(finding);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
eligible.push(finding);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const positives = selectPositiveSignals(input.collector.positives);
|
|
396
|
+
const reviewObservations = deduplicateReviewObservations(input.collector.reviewObservations, positives);
|
|
397
|
+
return omitUndefined({
|
|
398
|
+
adversary: input.adversary,
|
|
399
|
+
target: omitUndefined({
|
|
400
|
+
repository: input.repository,
|
|
401
|
+
filesScanned: input.filesScanned,
|
|
402
|
+
}),
|
|
403
|
+
assessment: input.collector.assessment ?? synthesizeAssessment(eligible, positives),
|
|
404
|
+
positives,
|
|
405
|
+
observations: reviewObservations,
|
|
406
|
+
scores: input.collector.scores.length > 0 ? deduplicateScores(input.collector.scores) : undefined,
|
|
407
|
+
findings: eligible,
|
|
408
|
+
opinion: input.collector.opinion ?? synthesizeOpinion(eligible),
|
|
409
|
+
suppressed: {
|
|
410
|
+
observations: 0,
|
|
411
|
+
findings: suppressedFindings.length,
|
|
412
|
+
},
|
|
413
|
+
timing: input.timing,
|
|
414
|
+
suppressedFindings: input.includeSuppressed ? suppressedFindings : undefined,
|
|
415
|
+
rawObservations: input.includeRawObservations ? input.collector.observations : undefined,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function synthesizeObservationFindings(observations, thresholds) {
|
|
419
|
+
const grouped = new Map();
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
for (const observation of observations) {
|
|
422
|
+
const rule = ruleRegistry.lookup(observation.ruleId);
|
|
423
|
+
const groupKey = observation.groupKey ?? defaultObservationGroupKey(observation, rule);
|
|
424
|
+
const dedupeKey = stableStringify({ groupKey, observation });
|
|
425
|
+
if (observation.deduplicate !== false && seen.has(dedupeKey)) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
seen.add(dedupeKey);
|
|
429
|
+
grouped.set(groupKey, [...(grouped.get(groupKey) ?? []), observation]);
|
|
430
|
+
}
|
|
431
|
+
return [...grouped.entries()].map(([groupKey, group]) => {
|
|
432
|
+
const first = group[0];
|
|
433
|
+
if (first === undefined) {
|
|
434
|
+
throw new Error("Cannot synthesize finding from an empty observation group.");
|
|
435
|
+
}
|
|
436
|
+
const rule = ruleRegistry.lookup(first.ruleId);
|
|
437
|
+
const synthesis = rule?.aggregate?.(group) ?? {};
|
|
438
|
+
const synthesisSource = rule?.aggregate === undefined ? "generic" : "rule";
|
|
439
|
+
log.debug(`review synthesis ruleId=${first.ruleId} groupKey=${groupKey} observations=${group.length} selected=${synthesisSource} fallback=${synthesisSource === "generic"}`);
|
|
440
|
+
const evidence = deduplicateEvidence(synthesis.evidence ?? group.map(observationToEvidence));
|
|
441
|
+
const recommendation = synthesis.recommendation === undefined
|
|
442
|
+
? synthesizeRecommendation(group)
|
|
443
|
+
: normalizeParagraph(synthesis.recommendation);
|
|
444
|
+
const confidence = aggregateConfidence(group.map((observation) => normalizeConfidence(observation.confidence ?? rule?.defaultConfidence ?? Confidence.Medium, thresholds)), first.confidenceAggregation ?? "maximum");
|
|
445
|
+
const severity = aggregateSeverity(group.map((observation) => observation.severity ?? rule?.defaultSeverity ?? Severity.Info), first.severityAggregation ?? "highest");
|
|
446
|
+
return {
|
|
447
|
+
id: synthesis.id ?? stableId(`${first.ruleId}:${groupKey}`),
|
|
448
|
+
ruleId: synthesis.ruleId ?? first.ruleId,
|
|
449
|
+
groupKey: synthesis.groupKey ?? groupKey,
|
|
450
|
+
title: synthesis.title ??
|
|
451
|
+
synthesizeObservationTitle(first.title, group.length, first.groupedTitle),
|
|
452
|
+
category: synthesis.category ?? first.category ?? rule?.category ?? "general",
|
|
453
|
+
severity: synthesis.severity ?? severity,
|
|
454
|
+
confidence: synthesis.confidence ??
|
|
455
|
+
(rule?.defaultConfidence === undefined
|
|
456
|
+
? confidence
|
|
457
|
+
: normalizeConfidence(rule.defaultConfidence, thresholds)),
|
|
458
|
+
summary: synthesis.summary ?? summarizeObservationGroup(group),
|
|
459
|
+
whyItMatters: synthesis.whyItMatters ?? first.whyItMatters,
|
|
460
|
+
impact: synthesis.impact ?? first.impact,
|
|
461
|
+
evidence,
|
|
462
|
+
recommendation: recommendation.length > 0 ? recommendation : undefined,
|
|
463
|
+
remediation: synthesis.remediation ?? first.remediation,
|
|
464
|
+
synthesisSource,
|
|
465
|
+
tags: synthesis.tags ?? uniqueStrings(group.flatMap((observation) => observation.tags ?? [])),
|
|
466
|
+
metadata: synthesis.metadata ?? first.metadata,
|
|
467
|
+
};
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
function normalizeFindingInput(input) {
|
|
471
|
+
return omitUndefined({
|
|
472
|
+
id: input.id ?? stableId(`${input.ruleId ?? input.title}:${input.groupKey ?? input.category}`),
|
|
473
|
+
ruleId: input.ruleId,
|
|
474
|
+
groupKey: input.groupKey,
|
|
475
|
+
title: input.title,
|
|
476
|
+
category: input.category,
|
|
477
|
+
severity: input.severity,
|
|
478
|
+
confidence: normalizeConfidence(input.confidence),
|
|
479
|
+
summary: input.summary,
|
|
480
|
+
whyItMatters: input.whyItMatters,
|
|
481
|
+
impact: input.impact,
|
|
482
|
+
evidence: deduplicateEvidence(input.evidence),
|
|
483
|
+
recommendation: input.recommendation === undefined ? undefined : normalizeParagraph(input.recommendation),
|
|
484
|
+
remediation: input.remediation,
|
|
485
|
+
tags: input.tags === undefined ? undefined : uniqueStrings(input.tags),
|
|
486
|
+
metadata: input.metadata,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
function deduplicateFindings(findings) {
|
|
490
|
+
const seen = new Set();
|
|
491
|
+
const result = [];
|
|
492
|
+
for (const finding of findings) {
|
|
493
|
+
const key = finding.groupKey ?? finding.id;
|
|
494
|
+
if (seen.has(key)) {
|
|
495
|
+
const existing = result.find((item) => (item.groupKey ?? item.id) === key);
|
|
496
|
+
if (existing !== undefined) {
|
|
497
|
+
existing.evidence = deduplicateEvidence([...existing.evidence, ...finding.evidence]);
|
|
498
|
+
existing.tags = uniqueStrings([...(existing.tags ?? []), ...(finding.tags ?? [])]);
|
|
499
|
+
}
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
seen.add(key);
|
|
503
|
+
result.push({ ...finding, evidence: deduplicateEvidence(finding.evidence) });
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
function deduplicateEvidence(evidence) {
|
|
508
|
+
const seen = new Set();
|
|
509
|
+
const result = [];
|
|
510
|
+
for (const item of evidence) {
|
|
511
|
+
const normalized = omitUndefined(item);
|
|
512
|
+
const key = stableStringify(normalized);
|
|
513
|
+
if (!seen.has(key)) {
|
|
514
|
+
seen.add(key);
|
|
515
|
+
result.push(normalized);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return result.sort((left, right) => {
|
|
519
|
+
const fileComparison = compareStrings(left.file ?? "", right.file ?? "");
|
|
520
|
+
if (fileComparison !== 0) {
|
|
521
|
+
return fileComparison;
|
|
522
|
+
}
|
|
523
|
+
const lineComparison = compareNumbers(left.line, right.line);
|
|
524
|
+
if (lineComparison !== 0) {
|
|
525
|
+
return lineComparison;
|
|
526
|
+
}
|
|
527
|
+
return compareStrings(left.message ?? "", right.message ?? "");
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function deduplicateNotes(notes) {
|
|
531
|
+
const seen = new Set();
|
|
532
|
+
const result = [];
|
|
533
|
+
for (const note of notes) {
|
|
534
|
+
const normalized = {
|
|
535
|
+
...note,
|
|
536
|
+
evidence: note.evidence === undefined ? undefined : deduplicateEvidence(note.evidence),
|
|
537
|
+
};
|
|
538
|
+
const key = note.key;
|
|
539
|
+
if (!seen.has(key)) {
|
|
540
|
+
seen.add(key);
|
|
541
|
+
result.push(normalized);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return result.sort((left, right) => compareStrings(left.key, right.key));
|
|
545
|
+
}
|
|
546
|
+
function selectPositiveSignals(notes) {
|
|
547
|
+
const selected = [];
|
|
548
|
+
const seen = new Set();
|
|
549
|
+
// Registration order expresses the review author's priority. Two strong signals
|
|
550
|
+
// provide useful context without diluting the findings.
|
|
551
|
+
for (const note of notes) {
|
|
552
|
+
if (!seen.has(note.key)) {
|
|
553
|
+
seen.add(note.key);
|
|
554
|
+
selected.push(note);
|
|
555
|
+
}
|
|
556
|
+
if (selected.length === 2) {
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return deduplicateNotes(selected);
|
|
561
|
+
}
|
|
562
|
+
function deduplicateReviewObservations(observations, positives) {
|
|
563
|
+
const deduped = deduplicateNotes(observations);
|
|
564
|
+
return deduped.filter((item) => {
|
|
565
|
+
return !positives.some((positive) => notesDescribeSameFact(positive, item));
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function notesDescribeSameFact(positive, observation) {
|
|
569
|
+
if (positive.key === observation.key) {
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
const positiveText = normalizeSemanticText(`${positive.key} ${positive.summary}`);
|
|
573
|
+
const observationText = normalizeSemanticText(`${observation.key} ${observation.summary}`);
|
|
574
|
+
if (positiveText.length > 0 && positiveText === observationText) {
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
const positiveSignals = highSignalSemanticTokens(positiveText);
|
|
578
|
+
const observationSignals = highSignalSemanticTokens(observationText);
|
|
579
|
+
return positiveSignals.some((token) => observationSignals.includes(token));
|
|
580
|
+
}
|
|
581
|
+
function synthesizeAssessment(findings, positives = []) {
|
|
582
|
+
const strength = assessmentStrength(positives[0], findings);
|
|
583
|
+
if (findings.length === 0) {
|
|
584
|
+
return {
|
|
585
|
+
risk: "none",
|
|
586
|
+
summary: joinSentences(strength, "No material concerns were identified in the reviewed repository."),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const risk = highestRisk(findings);
|
|
590
|
+
const primary = findings[0];
|
|
591
|
+
const primaryConcern = primary === undefined ? "the highest-ranked finding" : assessmentConcern(primary);
|
|
592
|
+
if (findings.length === 1) {
|
|
593
|
+
return {
|
|
594
|
+
risk,
|
|
595
|
+
summary: joinSentences(strength, `The only material concern identified is ${primaryConcern}.`),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
risk,
|
|
600
|
+
summary: joinSentences(strength, `${numberWord(findings.length)} material concerns were identified. The highest-value improvement is ${primaryConcern}.`),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function assessmentStrength(positive, findings) {
|
|
604
|
+
if (positive === undefined) {
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
if (findingsReferenceDockerfile(findings)) {
|
|
608
|
+
return "This is a well-structured production Dockerfile.";
|
|
609
|
+
}
|
|
610
|
+
const summary = normalizeParagraph(positive.summary);
|
|
611
|
+
if (/^uses\b/i.test(summary)) {
|
|
612
|
+
return `The repository ${lowercaseFirst(summary)}`;
|
|
613
|
+
}
|
|
614
|
+
return summary;
|
|
615
|
+
}
|
|
616
|
+
function assessmentConcern(finding) {
|
|
617
|
+
const title = findingConcern(finding);
|
|
618
|
+
const recommendation = recommendationSubject(finding.recommendation);
|
|
619
|
+
if (recommendation === "Digest pinning" || /base images?.*digest/i.test(title)) {
|
|
620
|
+
const plural = /base images\b/i.test(title);
|
|
621
|
+
return `that the base ${plural ? "images are" : "image is"} referenced by mutable tags rather than immutable digests`;
|
|
622
|
+
}
|
|
623
|
+
const summary = normalizeParagraph(finding.summary).split(/(?<=[.!?])\s+/, 1)[0];
|
|
624
|
+
return concernClause(lowercaseFirst(trimTrailingSentencePunctuation(summary ?? findingConcern(finding))));
|
|
625
|
+
}
|
|
626
|
+
function concernClause(concern) {
|
|
627
|
+
const isClause = /\b(?:allows|are|builds|can|contains|copies|could|did|do|does|exposes|has|have|includes|installs|is|lacks|may|might|must|reads|references|relies|requires|runs|uses|was|were|writes)\b/i.test(concern);
|
|
628
|
+
if (!isClause) {
|
|
629
|
+
return concern;
|
|
630
|
+
}
|
|
631
|
+
const hasDeterminer = /^(?:a|an|any|each|its|no|one|some|the|their|these|this|those)\b/i.test(concern);
|
|
632
|
+
return `that ${hasDeterminer ? concern : `the ${concern}`}`;
|
|
633
|
+
}
|
|
634
|
+
function joinSentences(...sentences) {
|
|
635
|
+
return sentences.filter(isNonEmptyString).join(" ");
|
|
636
|
+
}
|
|
637
|
+
function synthesizeOpinion(findings) {
|
|
638
|
+
if (findings.length === 0) {
|
|
639
|
+
return {
|
|
640
|
+
ship: true,
|
|
641
|
+
summary: "I would ship this as-is.",
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const highestSeverity = highestFindingSeverity(findings);
|
|
645
|
+
const ship = severityWeight(highestSeverity) < severityWeight(Severity.High);
|
|
646
|
+
const subject = findingsReferenceDockerfile(findings) ? "this Dockerfile" : "this";
|
|
647
|
+
if (findings.length > 1) {
|
|
648
|
+
return {
|
|
649
|
+
ship,
|
|
650
|
+
summary: "I would address the remaining findings before production.",
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
const finding = findings[0];
|
|
654
|
+
const improvement = finding === undefined ? "Addressing the finding" : improvementPhrase(finding);
|
|
655
|
+
return {
|
|
656
|
+
ship,
|
|
657
|
+
summary: ship
|
|
658
|
+
? `I would ship ${subject} as-is. ${improvement} is the only improvement I would recommend before production.`
|
|
659
|
+
: `${improvement} is the most important improvement to address before production.`,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
function findingsReferenceDockerfile(findings) {
|
|
663
|
+
const files = findings.flatMap((finding) => finding.evidence
|
|
664
|
+
.map((evidence) => evidence.location?.file ?? evidence.file)
|
|
665
|
+
.filter(isNonEmptyString));
|
|
666
|
+
return files.length > 0 && files.every((file) => /(?:^|\/)Dockerfile$/.test(file));
|
|
667
|
+
}
|
|
668
|
+
function deduplicateScores(scores) {
|
|
669
|
+
const seen = new Set();
|
|
670
|
+
const result = [];
|
|
671
|
+
for (const score of scores) {
|
|
672
|
+
if (!seen.has(score.key)) {
|
|
673
|
+
seen.add(score.key);
|
|
674
|
+
result.push(score);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return result.sort((left, right) => compareStrings(left.key, right.key));
|
|
678
|
+
}
|
|
679
|
+
function observationToEvidence(observation) {
|
|
680
|
+
const metadata = isRecord(observation.evidence)
|
|
681
|
+
? observation.evidence
|
|
682
|
+
: observation.evidence === undefined
|
|
683
|
+
? undefined
|
|
684
|
+
: { evidence: observation.evidence };
|
|
685
|
+
const message = isRecord(observation.evidence)
|
|
686
|
+
? structuredEvidenceMessage(observation.evidence)
|
|
687
|
+
: stringFromUnknown(observation.evidence);
|
|
688
|
+
const snippet = isRecord(observation.evidence)
|
|
689
|
+
? (stringFromUnknown(observation.evidence.snippet) ??
|
|
690
|
+
stringFromUnknown(observation.evidence.instruction))
|
|
691
|
+
: observation.location?.snippet;
|
|
692
|
+
return omitUndefined({
|
|
693
|
+
location: observation.location?.location ?? {
|
|
694
|
+
file: observation.location?.file,
|
|
695
|
+
line: observation.location?.line,
|
|
696
|
+
endLine: observation.location?.endLine,
|
|
697
|
+
},
|
|
698
|
+
file: observation.location?.file,
|
|
699
|
+
line: observation.location?.line,
|
|
700
|
+
endLine: observation.location?.endLine,
|
|
701
|
+
label: observation.location?.label ?? message,
|
|
702
|
+
message: observation.location?.message ?? message,
|
|
703
|
+
snippet,
|
|
704
|
+
data: metadata,
|
|
705
|
+
metadata,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
function structuredEvidenceMessage(evidence) {
|
|
709
|
+
const explicitMessage = stringFromUnknown(evidence.message);
|
|
710
|
+
if (explicitMessage !== undefined) {
|
|
711
|
+
return explicitMessage;
|
|
712
|
+
}
|
|
713
|
+
const stage = stringFromUnknown(evidence.stage);
|
|
714
|
+
if (stage !== undefined) {
|
|
715
|
+
return `${stage} stage`;
|
|
716
|
+
}
|
|
717
|
+
const label = stringFromUnknown(evidence.label) ?? stringFromUnknown(evidence.name);
|
|
718
|
+
if (label !== undefined) {
|
|
719
|
+
return label;
|
|
720
|
+
}
|
|
721
|
+
return stringFromUnknown(evidence.summary) ?? stringFromUnknown(evidence.instruction);
|
|
722
|
+
}
|
|
723
|
+
function synthesizeObservationTitle(title, count, groupedTitle) {
|
|
724
|
+
if (typeof title === "string") {
|
|
725
|
+
return count > 1 && groupedTitle !== undefined ? groupedTitle : title;
|
|
726
|
+
}
|
|
727
|
+
return count > 1 ? title.plural : title.singular;
|
|
728
|
+
}
|
|
729
|
+
function summarizeObservationGroup(group) {
|
|
730
|
+
const first = group[0];
|
|
731
|
+
if (first === undefined) {
|
|
732
|
+
throw new Error("Cannot summarize an empty observation group.");
|
|
733
|
+
}
|
|
734
|
+
if (first.summary !== undefined) {
|
|
735
|
+
if (typeof first.summary === "string") {
|
|
736
|
+
return normalizeParagraph(renderObservationTemplate(first.summary, group));
|
|
737
|
+
}
|
|
738
|
+
const template = group.length > 1 ? first.summary.grouped : first.summary.singular;
|
|
739
|
+
if (template !== undefined) {
|
|
740
|
+
return normalizeParagraph(renderObservationTemplate(template, group));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const recommendation = typeof first.recommendation === "string" ? undefined : first.recommendation?.summary;
|
|
744
|
+
if (group.length === 1) {
|
|
745
|
+
return recommendation ?? synthesizeObservationTitle(first.title, 1, first.groupedTitle);
|
|
746
|
+
}
|
|
747
|
+
return `${group.length} related observations were reported for ${first.subject}.`;
|
|
748
|
+
}
|
|
749
|
+
function synthesizeRecommendation(group) {
|
|
750
|
+
const recommendations = uniqueStrings(group.flatMap((observation) => {
|
|
751
|
+
if (typeof observation.recommendation === "string") {
|
|
752
|
+
return [observation.recommendation];
|
|
753
|
+
}
|
|
754
|
+
if (observation.recommendation !== undefined) {
|
|
755
|
+
return [
|
|
756
|
+
joinRecommendation(observation.recommendation.summary, observation.recommendation.details),
|
|
757
|
+
];
|
|
758
|
+
}
|
|
759
|
+
return [];
|
|
760
|
+
}));
|
|
761
|
+
return recommendations.map(normalizeParagraph).join("\n\n");
|
|
762
|
+
}
|
|
763
|
+
function joinRecommendation(summary, details) {
|
|
764
|
+
if (!isNonEmptyString(details)) {
|
|
765
|
+
return summary;
|
|
766
|
+
}
|
|
767
|
+
const compactSummary = trimTrailingSentencePunctuation(summary);
|
|
768
|
+
const compactDetails = trimTrailingSentencePunctuation(details);
|
|
769
|
+
if (/^use\s+/i.test(compactDetails)) {
|
|
770
|
+
return `${compactSummary} and ${lowercaseFirst(compactDetails)}.`;
|
|
771
|
+
}
|
|
772
|
+
return `${compactSummary}. ${compactDetails}.`;
|
|
773
|
+
}
|
|
774
|
+
function defaultObservationGroupKey(observation, rule) {
|
|
775
|
+
const groupBy = observation.groupBy ?? rule?.groupBy;
|
|
776
|
+
if (groupBy !== undefined && groupBy.length > 0) {
|
|
777
|
+
return groupBy
|
|
778
|
+
.map((field) => `${field}:${stringFromUnknown(observationValue(observation, field)) ?? ""}`)
|
|
779
|
+
.join("|");
|
|
780
|
+
}
|
|
781
|
+
return `${observation.ruleId}:${observation.subject}:${observation.category ?? rule?.category ?? "general"}`;
|
|
782
|
+
}
|
|
783
|
+
function renderObservationTemplate(template, group) {
|
|
784
|
+
const values = observationTemplateValues(group);
|
|
785
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (match, key) => {
|
|
786
|
+
return values[key] ?? match;
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
function observationTemplateValues(group) {
|
|
790
|
+
const first = group[0];
|
|
791
|
+
const subjects = uniqueStrings(group.map((observation) => observation.subject));
|
|
792
|
+
const stages = uniqueStrings(group.map(extractStage).filter(isNonEmptyString));
|
|
793
|
+
const locations = uniqueStrings(group.map(formatObservationLocation).filter(isNonEmptyString));
|
|
794
|
+
return omitUndefined({
|
|
795
|
+
count: numberWord(group.length),
|
|
796
|
+
location: locations[0],
|
|
797
|
+
locations: joinHumanList(locations),
|
|
798
|
+
subject: first?.subject,
|
|
799
|
+
subjects: joinHumanList(subjects),
|
|
800
|
+
stage: stages[0],
|
|
801
|
+
stages: joinHumanList(stages),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
function observationValue(observation, field) {
|
|
805
|
+
if (field.includes(".")) {
|
|
806
|
+
return field.split(".").reduce((value, part) => {
|
|
807
|
+
return isRecord(value) ? value[part] : undefined;
|
|
808
|
+
}, observation);
|
|
809
|
+
}
|
|
810
|
+
return observation[field];
|
|
811
|
+
}
|
|
812
|
+
function extractStage(observation) {
|
|
813
|
+
if (isRecord(observation.evidence)) {
|
|
814
|
+
const explicit = stringFromUnknown(observation.evidence.stage);
|
|
815
|
+
if (explicit !== undefined) {
|
|
816
|
+
return explicit;
|
|
817
|
+
}
|
|
818
|
+
const label = stringFromUnknown(observation.evidence.label);
|
|
819
|
+
if (label !== undefined) {
|
|
820
|
+
return trimTrailingWord(label, "stage");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return trimTrailingWord(observation.location?.label, "stage");
|
|
824
|
+
}
|
|
825
|
+
function formatObservationLocation(observation) {
|
|
826
|
+
if (observation.location?.file === undefined) {
|
|
827
|
+
return undefined;
|
|
828
|
+
}
|
|
829
|
+
if (observation.location.line === undefined) {
|
|
830
|
+
return observation.location.file;
|
|
831
|
+
}
|
|
832
|
+
return `${observation.location.file}:${observation.location.line}`;
|
|
833
|
+
}
|
|
834
|
+
function joinHumanList(values) {
|
|
835
|
+
if (values.length <= 2) {
|
|
836
|
+
return values.join(" and ");
|
|
837
|
+
}
|
|
838
|
+
return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
|
|
839
|
+
}
|
|
840
|
+
function highestRisk(findings) {
|
|
841
|
+
const severity = highestFindingSeverity(findings);
|
|
842
|
+
if (severity === Severity.Info) {
|
|
843
|
+
return "none";
|
|
844
|
+
}
|
|
845
|
+
return severity;
|
|
846
|
+
}
|
|
847
|
+
function highestFindingSeverity(findings) {
|
|
848
|
+
return aggregateSeverity(findings.map((finding) => finding.severity), "highest");
|
|
849
|
+
}
|
|
850
|
+
function findingConcern(finding) {
|
|
851
|
+
return lowercaseFirst(trimTrailingSentencePunctuation(finding.title));
|
|
852
|
+
}
|
|
853
|
+
function improvementPhrase(finding) {
|
|
854
|
+
const recommendation = recommendationSubject(finding.recommendation);
|
|
855
|
+
if (recommendation !== undefined) {
|
|
856
|
+
return recommendation;
|
|
857
|
+
}
|
|
858
|
+
return `Addressing ${findingConcern(finding)}`;
|
|
859
|
+
}
|
|
860
|
+
function recommendationSubject(recommendation) {
|
|
861
|
+
if (recommendation === undefined) {
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
const normalized = trimTrailingSentencePunctuation(normalizeParagraph(recommendation));
|
|
865
|
+
const pinDigest = normalized.match(/\bpin\b.*\bby digest\b/i);
|
|
866
|
+
if (pinDigest !== null) {
|
|
867
|
+
return "Digest pinning";
|
|
868
|
+
}
|
|
869
|
+
const firstClause = normalized.split(/\s+(?:and|when|where|with)\s+/i)[0]?.trim();
|
|
870
|
+
if (!isNonEmptyString(firstClause)) {
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
return gerundPhrase(firstClause);
|
|
874
|
+
}
|
|
875
|
+
function gerundPhrase(phrase) {
|
|
876
|
+
const words = phrase.split(/\s+/);
|
|
877
|
+
const first = words[0];
|
|
878
|
+
if (first === undefined) {
|
|
879
|
+
return phrase;
|
|
880
|
+
}
|
|
881
|
+
return capitalize([toGerund(first), ...words.slice(1)].join(" "));
|
|
882
|
+
}
|
|
883
|
+
function toGerund(verb) {
|
|
884
|
+
const lower = verb.toLowerCase();
|
|
885
|
+
const irregular = {
|
|
886
|
+
run: "running",
|
|
887
|
+
use: "using",
|
|
888
|
+
};
|
|
889
|
+
const known = irregular[lower];
|
|
890
|
+
if (known !== undefined) {
|
|
891
|
+
return known;
|
|
892
|
+
}
|
|
893
|
+
if (lower.endsWith("e") && !lower.endsWith("ee")) {
|
|
894
|
+
return `${lower.slice(0, -1)}ing`;
|
|
895
|
+
}
|
|
896
|
+
return `${lower}ing`;
|
|
897
|
+
}
|
|
898
|
+
function highSignalSemanticTokens(value) {
|
|
899
|
+
return value
|
|
900
|
+
.split(" ")
|
|
901
|
+
.map(canonicalSemanticToken)
|
|
902
|
+
.filter((word) => highSignalReviewTerms.has(word));
|
|
903
|
+
}
|
|
904
|
+
function canonicalSemanticToken(value) {
|
|
905
|
+
if (value === "stages") {
|
|
906
|
+
return "stage";
|
|
907
|
+
}
|
|
908
|
+
if (value === "artifacts") {
|
|
909
|
+
return "artifact";
|
|
910
|
+
}
|
|
911
|
+
return trimTrailingWord(trimTrailingWord(value, "ing"), "ed") ?? value;
|
|
912
|
+
}
|
|
913
|
+
function normalizeSemanticText(value) {
|
|
914
|
+
return value
|
|
915
|
+
.toLowerCase()
|
|
916
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
917
|
+
.split(/\s+/)
|
|
918
|
+
.filter((word) => word.length > 0 && !semanticStopWords.has(word))
|
|
919
|
+
.join(" ");
|
|
920
|
+
}
|
|
921
|
+
function trimTrailingWord(value, word) {
|
|
922
|
+
if (value === undefined) {
|
|
923
|
+
return undefined;
|
|
924
|
+
}
|
|
925
|
+
const pattern = new RegExp(`\\s+${word}$`, "i");
|
|
926
|
+
const trimmed = value.replace(pattern, "").trim();
|
|
927
|
+
return trimmed.length > 0 ? trimmed : value;
|
|
928
|
+
}
|
|
929
|
+
function numberWord(value) {
|
|
930
|
+
return ({
|
|
931
|
+
1: "One",
|
|
932
|
+
2: "Two",
|
|
933
|
+
3: "Three",
|
|
934
|
+
4: "Four",
|
|
935
|
+
5: "Five",
|
|
936
|
+
6: "Six",
|
|
937
|
+
7: "Seven",
|
|
938
|
+
8: "Eight",
|
|
939
|
+
9: "Nine",
|
|
940
|
+
10: "Ten",
|
|
941
|
+
}[value] ?? String(value));
|
|
942
|
+
}
|
|
943
|
+
function calibrateFindingSeverity(finding, policy) {
|
|
944
|
+
const override = policy.severityOverrides?.[finding.ruleId ?? ""] ??
|
|
945
|
+
policy.severityOverrides?.[finding.groupKey ?? ""];
|
|
946
|
+
return override === undefined ? finding : { ...finding, severity: override };
|
|
947
|
+
}
|
|
948
|
+
function scoreFinding(finding) {
|
|
949
|
+
const locationScore = Math.min(finding.evidence.length, 5) * 3;
|
|
950
|
+
const runtimeScore = finding.tags?.some((tag) => ["production", "runtime"].includes(tag)) ? 8 : 0;
|
|
951
|
+
const remediationScore = finding.remediation?.complexity === "trivial" ? 3 : 0;
|
|
952
|
+
return (severityWeight(finding.severity) * 10 +
|
|
953
|
+
confidenceWeight(finding.confidence) * 12 +
|
|
954
|
+
locationScore +
|
|
955
|
+
runtimeScore +
|
|
956
|
+
remediationScore);
|
|
957
|
+
}
|
|
958
|
+
function severityWeight(severity) {
|
|
959
|
+
switch (severity) {
|
|
960
|
+
case Severity.Critical:
|
|
961
|
+
return 5;
|
|
962
|
+
case Severity.High:
|
|
963
|
+
return 4;
|
|
964
|
+
case Severity.Medium:
|
|
965
|
+
return 3;
|
|
966
|
+
case Severity.Low:
|
|
967
|
+
return 2;
|
|
968
|
+
case Severity.Info:
|
|
969
|
+
return 1;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function confidenceWeight(confidence) {
|
|
973
|
+
switch (confidence) {
|
|
974
|
+
case Confidence.High:
|
|
975
|
+
return 3;
|
|
976
|
+
case Confidence.Medium:
|
|
977
|
+
return 2;
|
|
978
|
+
case Confidence.Low:
|
|
979
|
+
return 1;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function aggregateSeverity(values, strategy) {
|
|
983
|
+
const sorted = [...values].sort((left, right) => severityWeight(right) - severityWeight(left));
|
|
984
|
+
if (strategy === "lowest") {
|
|
985
|
+
return sorted.at(-1) ?? Severity.Info;
|
|
986
|
+
}
|
|
987
|
+
return sorted[0] ?? Severity.Info;
|
|
988
|
+
}
|
|
989
|
+
function aggregateConfidence(values, strategy) {
|
|
990
|
+
if (values.length === 0) {
|
|
991
|
+
return Confidence.Low;
|
|
992
|
+
}
|
|
993
|
+
if (strategy === "minimum") {
|
|
994
|
+
return ([...values].sort((left, right) => confidenceWeight(left) - confidenceWeight(right))[0] ??
|
|
995
|
+
Confidence.Low);
|
|
996
|
+
}
|
|
997
|
+
if (strategy === "average") {
|
|
998
|
+
const average = values.reduce((total, confidence) => total + confidenceNumericValue(confidence), 0) /
|
|
999
|
+
values.length;
|
|
1000
|
+
return normalizeConfidence(average);
|
|
1001
|
+
}
|
|
1002
|
+
return ([...values].sort((left, right) => confidenceWeight(right) - confidenceWeight(left))[0] ??
|
|
1003
|
+
Confidence.Low);
|
|
1004
|
+
}
|
|
1005
|
+
function confidenceNumericValue(confidence) {
|
|
1006
|
+
switch (confidence) {
|
|
1007
|
+
case Confidence.High:
|
|
1008
|
+
return 0.95;
|
|
1009
|
+
case Confidence.Medium:
|
|
1010
|
+
return 0.72;
|
|
1011
|
+
case Confidence.Low:
|
|
1012
|
+
return 0.3;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
function stableId(value) {
|
|
1016
|
+
let hash = 0;
|
|
1017
|
+
for (const character of value) {
|
|
1018
|
+
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
|
1019
|
+
}
|
|
1020
|
+
return `finding-${hash.toString(16).padStart(8, "0")}`;
|
|
1021
|
+
}
|
|
1022
|
+
function stableStringify(value) {
|
|
1023
|
+
if (Array.isArray(value)) {
|
|
1024
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
1025
|
+
}
|
|
1026
|
+
if (isRecord(value)) {
|
|
1027
|
+
return `{${Object.keys(value)
|
|
1028
|
+
.sort(compareStrings)
|
|
1029
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
1030
|
+
.join(",")}}`;
|
|
1031
|
+
}
|
|
1032
|
+
return JSON.stringify(value);
|
|
1033
|
+
}
|
|
1034
|
+
function uniqueStrings(values) {
|
|
1035
|
+
return [...new Set(values.filter(isNonEmptyString))].sort(compareStrings);
|
|
1036
|
+
}
|
|
1037
|
+
function assertObservationInit(value, source) {
|
|
1038
|
+
requireString(value.ruleId, `${source}.ruleId`);
|
|
1039
|
+
requireString(value.subject, `${source}.subject`);
|
|
1040
|
+
requireObservationTitle(value.title, `${source}.title`);
|
|
1041
|
+
const rule = ruleRegistry.lookup(value.ruleId);
|
|
1042
|
+
if (value.category === undefined && rule?.category === undefined) {
|
|
1043
|
+
throw new Error(`${source}.category is required when rule.category is not defined.`);
|
|
1044
|
+
}
|
|
1045
|
+
optionalString(value.category, `${source}.category`);
|
|
1046
|
+
if (value.severity === undefined && rule?.defaultSeverity === undefined) {
|
|
1047
|
+
throw new Error(`${source}.severity is required when rule.defaultSeverity is not defined.`);
|
|
1048
|
+
}
|
|
1049
|
+
if (value.severity !== undefined && !isSeverity(value.severity)) {
|
|
1050
|
+
throw new Error(`${source}.severity must be one of info, low, medium, high, critical.`);
|
|
1051
|
+
}
|
|
1052
|
+
const ruleConfidence = rule?.defaultConfidence;
|
|
1053
|
+
if (value.confidence === undefined && ruleConfidence === undefined) {
|
|
1054
|
+
throw new Error(`${source}.confidence is required when rule.defaultConfidence is not defined.`);
|
|
1055
|
+
}
|
|
1056
|
+
if (value.confidence !== undefined) {
|
|
1057
|
+
normalizeConfidence(value.confidence);
|
|
1058
|
+
}
|
|
1059
|
+
optionalObservationSummary(value.summary, `${source}.summary`);
|
|
1060
|
+
optionalEvidence(value.location, `${source}.location`);
|
|
1061
|
+
optionalString(value.groupKey, `${source}.groupKey`);
|
|
1062
|
+
optionalStringArray(value.groupBy, `${source}.groupBy`);
|
|
1063
|
+
optionalStringArray(value.tags, `${source}.tags`);
|
|
1064
|
+
}
|
|
1065
|
+
function assertFindingInput(value, source) {
|
|
1066
|
+
requireString(value.title, `${source}.title`);
|
|
1067
|
+
requireString(value.category, `${source}.category`);
|
|
1068
|
+
requireString(value.summary, `${source}.summary`);
|
|
1069
|
+
if (!isSeverity(value.severity)) {
|
|
1070
|
+
throw new Error(`${source}.severity must be one of info, low, medium, high, critical.`);
|
|
1071
|
+
}
|
|
1072
|
+
normalizeConfidence(value.confidence);
|
|
1073
|
+
if (!Array.isArray(value.evidence) || value.evidence.length === 0) {
|
|
1074
|
+
throw new Error(`${source}.evidence must contain at least one evidence item.`);
|
|
1075
|
+
}
|
|
1076
|
+
for (const [index, evidence] of value.evidence.entries()) {
|
|
1077
|
+
optionalEvidence(evidence, `${source}.evidence[${index}]`);
|
|
1078
|
+
}
|
|
1079
|
+
optionalString(value.id, `${source}.id`);
|
|
1080
|
+
optionalString(value.ruleId, `${source}.ruleId`);
|
|
1081
|
+
optionalString(value.groupKey, `${source}.groupKey`);
|
|
1082
|
+
optionalStringArray(value.tags, `${source}.tags`);
|
|
1083
|
+
}
|
|
1084
|
+
function assertReviewNote(value, source) {
|
|
1085
|
+
requireString(value.key, `${source}.key`);
|
|
1086
|
+
requireString(value.summary, `${source}.summary`);
|
|
1087
|
+
if (value.evidence !== undefined) {
|
|
1088
|
+
for (const [index, evidence] of value.evidence.entries()) {
|
|
1089
|
+
optionalEvidence(evidence, `${source}.evidence[${index}]`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function assertReviewScore(value) {
|
|
1094
|
+
requireString(value.key, "ctx.review.score.key");
|
|
1095
|
+
if (typeof value.score !== "number" || Number.isNaN(value.score)) {
|
|
1096
|
+
throw new Error("ctx.review.score.score must be a number.");
|
|
1097
|
+
}
|
|
1098
|
+
if (value.max !== undefined && (typeof value.max !== "number" || Number.isNaN(value.max))) {
|
|
1099
|
+
throw new Error("ctx.review.score.max must be a number.");
|
|
1100
|
+
}
|
|
1101
|
+
optionalString(value.label, "ctx.review.score.label");
|
|
1102
|
+
optionalString(value.summary, "ctx.review.score.summary");
|
|
1103
|
+
}
|
|
1104
|
+
function assertAssessment(value) {
|
|
1105
|
+
if (!["none", "low", "medium", "high", "critical"].includes(value.risk)) {
|
|
1106
|
+
throw new Error("ctx.review.assessment.risk must be one of none, low, medium, high, critical.");
|
|
1107
|
+
}
|
|
1108
|
+
optionalString(value.summary, "ctx.review.assessment.summary");
|
|
1109
|
+
}
|
|
1110
|
+
function assertOpinion(value) {
|
|
1111
|
+
requireString(value.summary, "ctx.review.opinion.summary");
|
|
1112
|
+
}
|
|
1113
|
+
function assertRuleDefinition(rule) {
|
|
1114
|
+
requireString(rule.id, "rule.id");
|
|
1115
|
+
optionalString(rule.category, "rule.category");
|
|
1116
|
+
if (rule.defaultSeverity !== undefined && !isSeverity(rule.defaultSeverity)) {
|
|
1117
|
+
throw new Error("rule.defaultSeverity must be one of info, low, medium, high, critical.");
|
|
1118
|
+
}
|
|
1119
|
+
if (rule.defaultConfidence !== undefined) {
|
|
1120
|
+
normalizeConfidence(rule.defaultConfidence);
|
|
1121
|
+
}
|
|
1122
|
+
optionalStringArray(rule.groupBy, "rule.groupBy");
|
|
1123
|
+
if (rule.aggregate !== undefined && typeof rule.aggregate !== "function") {
|
|
1124
|
+
throw new Error("rule.aggregate must be a function.");
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
function requireObservationTitle(value, field) {
|
|
1128
|
+
if (typeof value === "string") {
|
|
1129
|
+
requireString(value, field);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (!isRecord(value)) {
|
|
1133
|
+
throw new Error(`${field} must be a string or { singular, plural }.`);
|
|
1134
|
+
}
|
|
1135
|
+
requireString(value.singular, `${field}.singular`);
|
|
1136
|
+
requireString(value.plural, `${field}.plural`);
|
|
1137
|
+
}
|
|
1138
|
+
function optionalObservationSummary(value, field) {
|
|
1139
|
+
if (value === undefined) {
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (typeof value === "string") {
|
|
1143
|
+
optionalString(value, field);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (!isRecord(value)) {
|
|
1147
|
+
throw new Error(`${field} must be a string or { singular, grouped }.`);
|
|
1148
|
+
}
|
|
1149
|
+
optionalString(value.singular, `${field}.singular`);
|
|
1150
|
+
optionalString(value.grouped, `${field}.grouped`);
|
|
1151
|
+
}
|
|
1152
|
+
function optionalEvidence(value, field) {
|
|
1153
|
+
if (value === undefined) {
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (!isRecord(value)) {
|
|
1157
|
+
throw new Error(`${field} must be an object.`);
|
|
1158
|
+
}
|
|
1159
|
+
optionalString(value.file, `${field}.file`);
|
|
1160
|
+
optionalPositiveInteger(value.line, `${field}.line`);
|
|
1161
|
+
optionalPositiveInteger(value.endLine, `${field}.endLine`);
|
|
1162
|
+
optionalString(value.message, `${field}.message`);
|
|
1163
|
+
optionalString(value.snippet, `${field}.snippet`);
|
|
1164
|
+
optionalString(value.label, `${field}.label`);
|
|
1165
|
+
}
|
|
1166
|
+
function writeLog(level, message) {
|
|
1167
|
+
process.stderr.write(`[adversary] ${level}: ${String(message)}\n`);
|
|
1168
|
+
}
|
|
1169
|
+
function parseBooleanEnv(value) {
|
|
1170
|
+
if (value === undefined || value.length === 0) {
|
|
1171
|
+
return undefined;
|
|
1172
|
+
}
|
|
1173
|
+
return ["1", "true", "TRUE", "yes", "YES"].includes(value);
|
|
1174
|
+
}
|
|
1175
|
+
function isVerbose() {
|
|
1176
|
+
return verboseValues.has(process.env.ADVERSARY_VERBOSE ?? "");
|
|
1177
|
+
}
|
|
1178
|
+
function compareStrings(left, right) {
|
|
1179
|
+
return left.localeCompare(right);
|
|
1180
|
+
}
|
|
1181
|
+
function compareNumbers(left, right) {
|
|
1182
|
+
return (left ?? Number.MAX_SAFE_INTEGER) - (right ?? Number.MAX_SAFE_INTEGER);
|
|
1183
|
+
}
|
|
1184
|
+
function toPosixPath(path) {
|
|
1185
|
+
return path.replaceAll("\\", "/");
|
|
1186
|
+
}
|
|
1187
|
+
function basename(path) {
|
|
1188
|
+
return path.split("/").at(-1) ?? path;
|
|
1189
|
+
}
|
|
1190
|
+
function requireString(value, field) {
|
|
1191
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1192
|
+
throw new Error(`${field} must be a non-empty string.`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
function optionalString(value, field) {
|
|
1196
|
+
if (value !== undefined && typeof value !== "string") {
|
|
1197
|
+
throw new Error(`${field} must be a string.`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
function optionalStringArray(value, field) {
|
|
1201
|
+
if (value !== undefined &&
|
|
1202
|
+
(!Array.isArray(value) || value.some((item) => typeof item !== "string"))) {
|
|
1203
|
+
throw new Error(`${field} must be an array of strings.`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
function optionalPositiveInteger(value, field) {
|
|
1207
|
+
if (value !== undefined && (!Number.isInteger(value) || Number(value) < 1)) {
|
|
1208
|
+
throw new Error(`${field} must be a positive integer.`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
function isConfidence(value) {
|
|
1212
|
+
return value === Confidence.Low || value === Confidence.Medium || value === Confidence.High;
|
|
1213
|
+
}
|
|
1214
|
+
function isSeverity(value) {
|
|
1215
|
+
return (value === Severity.Info ||
|
|
1216
|
+
value === Severity.Low ||
|
|
1217
|
+
value === Severity.Medium ||
|
|
1218
|
+
value === Severity.High ||
|
|
1219
|
+
value === Severity.Critical);
|
|
1220
|
+
}
|
|
1221
|
+
function isNonEmptyString(value) {
|
|
1222
|
+
return typeof value === "string" && value.length > 0;
|
|
1223
|
+
}
|
|
1224
|
+
function isRecord(value) {
|
|
1225
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1226
|
+
}
|
|
1227
|
+
function stringFromUnknown(value) {
|
|
1228
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
1229
|
+
}
|
|
1230
|
+
function capitalize(value) {
|
|
1231
|
+
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
1232
|
+
}
|
|
1233
|
+
const semanticStopWords = new Set([
|
|
1234
|
+
"a",
|
|
1235
|
+
"an",
|
|
1236
|
+
"and",
|
|
1237
|
+
"are",
|
|
1238
|
+
"as",
|
|
1239
|
+
"from",
|
|
1240
|
+
"in",
|
|
1241
|
+
"is",
|
|
1242
|
+
"of",
|
|
1243
|
+
"the",
|
|
1244
|
+
"to",
|
|
1245
|
+
"uses",
|
|
1246
|
+
"using",
|
|
1247
|
+
]);
|
|
1248
|
+
const highSignalReviewTerms = new Set([
|
|
1249
|
+
"artifact",
|
|
1250
|
+
"builder",
|
|
1251
|
+
"digest",
|
|
1252
|
+
"multi",
|
|
1253
|
+
"runtime",
|
|
1254
|
+
"stage",
|
|
1255
|
+
]);
|
|
1256
|
+
function formatEvidenceLocation(evidence) {
|
|
1257
|
+
const file = evidence.location?.file ?? evidence.file;
|
|
1258
|
+
const line = evidence.location?.line ?? evidence.line;
|
|
1259
|
+
const endLine = evidence.location?.endLine ?? evidence.endLine;
|
|
1260
|
+
if (file === undefined) {
|
|
1261
|
+
return "";
|
|
1262
|
+
}
|
|
1263
|
+
if (line !== undefined && endLine !== undefined) {
|
|
1264
|
+
return `${file}:${line}-${endLine}`;
|
|
1265
|
+
}
|
|
1266
|
+
if (line !== undefined) {
|
|
1267
|
+
return `${file}:${line}`;
|
|
1268
|
+
}
|
|
1269
|
+
return file;
|
|
1270
|
+
}
|
|
1271
|
+
function formatEvidenceLines(evidence) {
|
|
1272
|
+
const location = formatEvidenceLocation(evidence);
|
|
1273
|
+
const label = evidence.label ?? evidence.message;
|
|
1274
|
+
const firstLine = location.length > 0 && label !== undefined
|
|
1275
|
+
? `- ${location} — ${label}`
|
|
1276
|
+
: `- ${location.length > 0 ? location : (label ?? "Evidence")}`;
|
|
1277
|
+
const lines = [firstLine];
|
|
1278
|
+
if (evidence.snippet !== undefined) {
|
|
1279
|
+
lines.push(` ${evidence.snippet}`);
|
|
1280
|
+
}
|
|
1281
|
+
return lines;
|
|
1282
|
+
}
|
|
1283
|
+
function formatScore(score) {
|
|
1284
|
+
const label = score.label ?? score.key;
|
|
1285
|
+
const max = score.max ?? 10;
|
|
1286
|
+
const summary = score.summary === undefined ? "" : ` - ${normalizeParagraph(score.summary)}`;
|
|
1287
|
+
return `${label}: ${score.score} / ${max}${summary}`;
|
|
1288
|
+
}
|
|
1289
|
+
function normalizeParagraph(value) {
|
|
1290
|
+
return value
|
|
1291
|
+
.split(/\n+/)
|
|
1292
|
+
.map((line) => line.trim())
|
|
1293
|
+
.filter((line) => line.length > 0)
|
|
1294
|
+
.join(" ");
|
|
1295
|
+
}
|
|
1296
|
+
function trimTrailingSentencePunctuation(value) {
|
|
1297
|
+
return value.trim().replace(/[.!?]+$/g, "");
|
|
1298
|
+
}
|
|
1299
|
+
function lowercaseFirst(value) {
|
|
1300
|
+
return `${value.slice(0, 1).toLowerCase()}${value.slice(1)}`;
|
|
1301
|
+
}
|
|
1302
|
+
function omitUndefined(value) {
|
|
1303
|
+
return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
|
|
1304
|
+
}
|
|
1305
|
+
//# sourceMappingURL=index.js.map
|