@adversarylabs/sdk 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -20
- package/dist/index.d.ts +69 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +338 -143
- package/dist/index.js.map +1 -1
- package/package.json +20 -5
- package/schemas/adversary.review.v1.schema.json +136 -0
- package/templates/basic/.dockerignore +5 -0
- package/templates/basic/Dockerfile +11 -4
- package/templates/basic/README.md +3 -1
- package/templates/basic/adversary.yaml +1 -1
- package/templates/basic/npm-shrinkwrap.json +1364 -0
- package/templates/basic/package.json +4 -4
- package/templates/basic/src/index.ts +2 -1
- package/templates/basic/test/index.test.ts +0 -4
- package/schemas/adversary.findings.v1.schema.json +0 -80
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
4
|
export const DEFAULT_INPUT_PATH = "/adversary/input.json";
|
|
4
5
|
export const DEFAULT_OUTPUT_PATH = "/adversary/output.json";
|
|
5
6
|
export const ADVERSARY_RUN_PROTOCOL_VERSION = 1;
|
|
6
7
|
const verboseValues = new Set(["1", "true", "TRUE", "yes", "YES"]);
|
|
8
|
+
let envelopeValidator;
|
|
7
9
|
export const Severity = {
|
|
8
10
|
Info: "info",
|
|
9
11
|
Low: "low",
|
|
@@ -46,19 +48,63 @@ export class RuleRegistry {
|
|
|
46
48
|
rules = new Map();
|
|
47
49
|
register(rule) {
|
|
48
50
|
assertRuleDefinition(rule);
|
|
49
|
-
this.rules.
|
|
51
|
+
if (this.rules.has(rule.id)) {
|
|
52
|
+
throw new Error(`Rule definition "${rule.id}" is already registered.`);
|
|
53
|
+
}
|
|
54
|
+
this.rules.set(rule.id, cloneRuleDefinition(rule));
|
|
55
|
+
}
|
|
56
|
+
replace(rule) {
|
|
57
|
+
assertRuleDefinition(rule);
|
|
58
|
+
if (!this.rules.has(rule.id)) {
|
|
59
|
+
throw new Error(`Rule definition "${rule.id}" is not registered.`);
|
|
60
|
+
}
|
|
61
|
+
this.rules.set(rule.id, cloneRuleDefinition(rule));
|
|
50
62
|
}
|
|
51
63
|
lookup(ruleId) {
|
|
52
|
-
|
|
64
|
+
const rule = this.rules.get(ruleId);
|
|
65
|
+
return rule === undefined ? undefined : cloneRuleDefinition(rule);
|
|
53
66
|
}
|
|
54
67
|
has(ruleId) {
|
|
55
68
|
return this.rules.has(ruleId);
|
|
56
69
|
}
|
|
70
|
+
snapshot() {
|
|
71
|
+
const snapshot = new RuleRegistry();
|
|
72
|
+
for (const rule of this.rules.values()) {
|
|
73
|
+
snapshot.register(rule);
|
|
74
|
+
}
|
|
75
|
+
return snapshot;
|
|
76
|
+
}
|
|
77
|
+
importMissing(source) {
|
|
78
|
+
for (const rule of source.rules.values()) {
|
|
79
|
+
if (!this.rules.has(rule.id)) {
|
|
80
|
+
this.rules.set(rule.id, cloneRuleDefinition(rule));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function cloneRuleDefinition(rule) {
|
|
86
|
+
return {
|
|
87
|
+
...rule,
|
|
88
|
+
groupBy: rule.groupBy === undefined ? undefined : [...rule.groupBy],
|
|
89
|
+
};
|
|
57
90
|
}
|
|
91
|
+
function cloneReviewPolicy(policy) {
|
|
92
|
+
return {
|
|
93
|
+
...policy,
|
|
94
|
+
confidenceThresholds: policy.confidenceThresholds === undefined ? undefined : { ...policy.confidenceThresholds },
|
|
95
|
+
severityOverrides: policy.severityOverrides === undefined ? undefined : { ...policy.severityOverrides },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** @deprecated Prefer app.defineRule(...) so definitions remain instance-scoped. */
|
|
58
99
|
export const ruleRegistry = new RuleRegistry();
|
|
100
|
+
/** @deprecated Prefer app.defineRule(...) so definitions remain instance-scoped. */
|
|
59
101
|
export function defineRule(rule) {
|
|
60
102
|
ruleRegistry.register(rule);
|
|
61
103
|
}
|
|
104
|
+
/** @deprecated Prefer app.replaceRule(...) so definitions remain instance-scoped. */
|
|
105
|
+
export function replaceRule(rule) {
|
|
106
|
+
ruleRegistry.replace(rule);
|
|
107
|
+
}
|
|
62
108
|
export const log = {
|
|
63
109
|
debug(message) {
|
|
64
110
|
if (isVerbose()) {
|
|
@@ -82,29 +128,48 @@ export class Adversary {
|
|
|
82
128
|
version;
|
|
83
129
|
rules = [];
|
|
84
130
|
reviewPolicy;
|
|
131
|
+
ruleDefinitions;
|
|
85
132
|
constructor(options) {
|
|
86
133
|
if (options.name.length === 0) {
|
|
87
134
|
throw new Error("Adversary name must be a non-empty string.");
|
|
88
135
|
}
|
|
89
136
|
this.name = options.name;
|
|
90
137
|
this.version = options.version;
|
|
91
|
-
this.reviewPolicy = options.review ?? {};
|
|
138
|
+
this.reviewPolicy = cloneReviewPolicy(options.review ?? {});
|
|
139
|
+
assertReviewPolicy(this.reviewPolicy, `adversary "${this.name}" review policy`);
|
|
140
|
+
this.ruleDefinitions = ruleRegistry.snapshot();
|
|
141
|
+
}
|
|
142
|
+
defineRule(rule) {
|
|
143
|
+
this.ruleDefinitions.register(rule);
|
|
144
|
+
}
|
|
145
|
+
replaceRule(rule) {
|
|
146
|
+
this.ruleDefinitions.replace(rule);
|
|
147
|
+
}
|
|
148
|
+
hasRuleDefinition(ruleId) {
|
|
149
|
+
return this.ruleDefinitions.has(ruleId);
|
|
92
150
|
}
|
|
93
151
|
rule(id, handler) {
|
|
94
152
|
if (id.length === 0) {
|
|
95
153
|
throw new Error("Rule id must be a non-empty string.");
|
|
96
154
|
}
|
|
155
|
+
if (this.rules.some((rule) => rule.id === id)) {
|
|
156
|
+
throw new Error(`App rule "${id}" is already registered.`);
|
|
157
|
+
}
|
|
158
|
+
// Compatibility for definitions registered with the deprecated top-level API after
|
|
159
|
+
// this Adversary was constructed. Once copied, later global changes cannot affect it.
|
|
160
|
+
this.ruleDefinitions.importMissing(ruleRegistry);
|
|
97
161
|
this.rules.push({ id, handler });
|
|
98
162
|
}
|
|
99
|
-
async run(options
|
|
163
|
+
async run(options) {
|
|
100
164
|
const startedAt = performance.now();
|
|
101
|
-
|
|
102
|
-
const repoPath =
|
|
165
|
+
assertReviewPolicy(options.review ?? {}, `adversary "${this.name}" run review policy`);
|
|
166
|
+
const repoPath = options.input.source.path;
|
|
103
167
|
const summary = {};
|
|
104
168
|
const cache = new Map();
|
|
105
169
|
const collector = createReviewCollector();
|
|
106
|
-
const
|
|
107
|
-
const
|
|
170
|
+
const registry = this.ruleDefinitions.snapshot();
|
|
171
|
+
const context = createRuleContext(repoPath, summary, cache, collector, registry);
|
|
172
|
+
const includeSuppressed = options.includeSuppressed;
|
|
108
173
|
for (const rule of this.rules) {
|
|
109
174
|
log.debug(`running rule ${rule.id}`);
|
|
110
175
|
await rule.handler(context);
|
|
@@ -114,24 +179,92 @@ export class Adversary {
|
|
|
114
179
|
repository: repoPath,
|
|
115
180
|
filesScanned: typeof summary.files_scanned === "number" ? summary.files_scanned : undefined,
|
|
116
181
|
collector,
|
|
117
|
-
policy: { ...this.reviewPolicy, ...options.review },
|
|
182
|
+
policy: cloneReviewPolicy({ ...this.reviewPolicy, ...options.review }),
|
|
183
|
+
registry,
|
|
118
184
|
includeSuppressed,
|
|
119
185
|
includeRawObservations: options.includeRawObservations,
|
|
120
|
-
timing:
|
|
186
|
+
timing: options.includeTiming
|
|
187
|
+
? { totalMs: Math.round(performance.now() - startedAt) }
|
|
188
|
+
: undefined,
|
|
121
189
|
});
|
|
122
|
-
if (options.write !== false) {
|
|
123
|
-
await writeOutput(createAdversaryRunEnvelope(output), options.outputPath);
|
|
124
|
-
}
|
|
125
190
|
return output;
|
|
126
191
|
}
|
|
192
|
+
async runFromEnvironment(options = {}) {
|
|
193
|
+
const input = options.input ??
|
|
194
|
+
(await parseInput(options.inputPath ?? process.env.ADVERSARY_INPUT ?? DEFAULT_INPUT_PATH));
|
|
195
|
+
const repository = options.input
|
|
196
|
+
? input.source.path
|
|
197
|
+
: (process.env.ADVERSARY_REPO ?? input.source.path);
|
|
198
|
+
const result = await this.run({
|
|
199
|
+
input: { ...input, source: { ...input.source, path: repository } },
|
|
200
|
+
review: options.review,
|
|
201
|
+
includeSuppressed: options.includeSuppressed ?? parseBooleanEnv(process.env.ADVERSARY_INCLUDE_SUPPRESSED),
|
|
202
|
+
includeRawObservations: options.includeRawObservations,
|
|
203
|
+
includeTiming: options.includeTiming,
|
|
204
|
+
});
|
|
205
|
+
await writeOutput(createAdversaryRunEnvelope(result), options.outputPath ?? process.env.ADVERSARY_OUTPUT ?? DEFAULT_OUTPUT_PATH);
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
127
208
|
}
|
|
128
209
|
export function createAdversaryRunEnvelope(result) {
|
|
129
210
|
return {
|
|
130
211
|
protocolVersion: ADVERSARY_RUN_PROTOCOL_VERSION,
|
|
131
|
-
result,
|
|
212
|
+
result: toWireReviewResult(result),
|
|
132
213
|
};
|
|
133
214
|
}
|
|
134
|
-
|
|
215
|
+
function toWireReviewResult(result) {
|
|
216
|
+
return omitUndefined({
|
|
217
|
+
adversary: omitUndefined(result.adversary),
|
|
218
|
+
target: omitUndefined(result.target),
|
|
219
|
+
assessment: result.assessment === undefined ? undefined : omitUndefined({ ...result.assessment }),
|
|
220
|
+
positives: result.positives.map(toWireReviewNote),
|
|
221
|
+
observations: result.observations.map(toWireReviewNote),
|
|
222
|
+
findings: result.findings.map(toWireFinding),
|
|
223
|
+
opinion: result.opinion === undefined ? undefined : omitUndefined({ ...result.opinion }),
|
|
224
|
+
suppressed: result.suppressed,
|
|
225
|
+
timing: result.timing === undefined ? undefined : omitUndefined(result.timing),
|
|
226
|
+
suppressedFindings: result.suppressedFindings?.map(toWireFinding),
|
|
227
|
+
rawObservations: result.rawObservations,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function toWireReviewNote(note) {
|
|
231
|
+
return omitUndefined({
|
|
232
|
+
key: note.key,
|
|
233
|
+
summary: note.summary,
|
|
234
|
+
evidence: note.evidence?.map(toWireEvidence),
|
|
235
|
+
metadata: note.metadata,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function toWireFinding(finding) {
|
|
239
|
+
return omitUndefined({
|
|
240
|
+
id: finding.id,
|
|
241
|
+
ruleId: finding.ruleId,
|
|
242
|
+
groupKey: finding.groupKey,
|
|
243
|
+
title: finding.title,
|
|
244
|
+
category: finding.category,
|
|
245
|
+
severity: finding.severity,
|
|
246
|
+
confidence: finding.confidence,
|
|
247
|
+
summary: finding.summary,
|
|
248
|
+
whyItMatters: finding.whyItMatters,
|
|
249
|
+
impact: finding.impact,
|
|
250
|
+
evidence: finding.evidence.map(toWireEvidence),
|
|
251
|
+
recommendation: finding.recommendation,
|
|
252
|
+
remediation: finding.remediation === undefined ? undefined : omitUndefined({ ...finding.remediation }),
|
|
253
|
+
tags: finding.tags,
|
|
254
|
+
metadata: finding.metadata,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function toWireEvidence(evidence) {
|
|
258
|
+
return omitUndefined({
|
|
259
|
+
file: evidence.location?.file,
|
|
260
|
+
line: evidence.location?.line,
|
|
261
|
+
endLine: evidence.location?.endLine,
|
|
262
|
+
message: evidence.message ?? evidence.label,
|
|
263
|
+
snippet: evidence.snippet,
|
|
264
|
+
metadata: evidence.data,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
export async function parseInput(path = DEFAULT_INPUT_PATH) {
|
|
135
268
|
const raw = await readFile(path, "utf8");
|
|
136
269
|
const parsed = JSON.parse(raw);
|
|
137
270
|
if (!isRecord(parsed)) {
|
|
@@ -145,10 +278,22 @@ export async function parseInput(path = process.env.ADVERSARY_INPUT ?? DEFAULT_I
|
|
|
145
278
|
}
|
|
146
279
|
return parsed;
|
|
147
280
|
}
|
|
148
|
-
export async function writeOutput(output, path =
|
|
281
|
+
export async function writeOutput(output, path = DEFAULT_OUTPUT_PATH) {
|
|
282
|
+
await validateRunEnvelope(output);
|
|
149
283
|
await mkdir(dirname(path), { recursive: true });
|
|
150
284
|
await writeFile(path, `${JSON.stringify(output, null, 2)}\n`, "utf8");
|
|
151
285
|
}
|
|
286
|
+
export async function validateRunEnvelope(output) {
|
|
287
|
+
let validator = envelopeValidator;
|
|
288
|
+
if (validator === undefined) {
|
|
289
|
+
const schema = JSON.parse(await readFile(new URL("../schemas/adversary.review.v1.schema.json", import.meta.url), "utf8"));
|
|
290
|
+
validator = new Ajv2020({ allErrors: true, strict: true }).compile(schema);
|
|
291
|
+
envelopeValidator = validator;
|
|
292
|
+
}
|
|
293
|
+
if (!validator(output)) {
|
|
294
|
+
throw new Error(`Invalid adversary.review.v1 envelope: ${JSON.stringify(validator.errors)}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
152
297
|
export function normalizeConfidence(confidence, thresholds = DEFAULT_CONFIDENCE_THRESHOLDS) {
|
|
153
298
|
if (isConfidence(confidence)) {
|
|
154
299
|
return confidence;
|
|
@@ -190,7 +335,7 @@ export class JsonRenderer {
|
|
|
190
335
|
this.write = write;
|
|
191
336
|
}
|
|
192
337
|
render(result) {
|
|
193
|
-
this.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
338
|
+
this.write(`${JSON.stringify(toWireReviewResult(result), null, 2)}\n`);
|
|
194
339
|
}
|
|
195
340
|
}
|
|
196
341
|
export class TerminalRenderer {
|
|
@@ -212,10 +357,12 @@ export class TerminalRenderer {
|
|
|
212
357
|
lines.push(normalizeParagraph(result.assessment.summary), "");
|
|
213
358
|
}
|
|
214
359
|
}
|
|
215
|
-
|
|
360
|
+
const scoreNotes = result.observations.filter(isScoreReviewNote);
|
|
361
|
+
const additionalObservations = result.observations.filter((note) => !isScoreReviewNote(note));
|
|
362
|
+
if (scoreNotes.length > 0) {
|
|
216
363
|
lines.push("Scores", "");
|
|
217
|
-
for (const
|
|
218
|
-
lines.push(
|
|
364
|
+
for (const note of scoreNotes) {
|
|
365
|
+
lines.push(note.summary);
|
|
219
366
|
}
|
|
220
367
|
lines.push("");
|
|
221
368
|
}
|
|
@@ -226,9 +373,9 @@ export class TerminalRenderer {
|
|
|
226
373
|
}
|
|
227
374
|
lines.push("");
|
|
228
375
|
}
|
|
229
|
-
if (
|
|
376
|
+
if (additionalObservations.length > 0) {
|
|
230
377
|
lines.push("Additional observations", "");
|
|
231
|
-
for (const observation of
|
|
378
|
+
for (const observation of additionalObservations) {
|
|
232
379
|
lines.push(`- ${normalizeParagraph(observation.summary)}`);
|
|
233
380
|
}
|
|
234
381
|
lines.push("");
|
|
@@ -248,8 +395,8 @@ export class TerminalRenderer {
|
|
|
248
395
|
lines.push(`Findings: ${result.findings.length}`, "");
|
|
249
396
|
for (const finding of result.findings) {
|
|
250
397
|
lines.push(`[${finding.severity}] ${finding.title}`);
|
|
251
|
-
const firstEvidence = finding.evidence.find((item) => item.file !== undefined);
|
|
252
|
-
if (firstEvidence?.file !== undefined) {
|
|
398
|
+
const firstEvidence = finding.evidence.find((item) => item.location?.file !== undefined);
|
|
399
|
+
if (firstEvidence?.location?.file !== undefined) {
|
|
253
400
|
lines.push(formatEvidenceLocation(firstEvidence));
|
|
254
401
|
}
|
|
255
402
|
lines.push("");
|
|
@@ -276,7 +423,7 @@ export class TerminalRenderer {
|
|
|
276
423
|
this.write(`${lines.join("\n").trimEnd()}\n`);
|
|
277
424
|
}
|
|
278
425
|
}
|
|
279
|
-
function createRuleContext(repoPath, summary, cache, collector) {
|
|
426
|
+
function createRuleContext(repoPath, summary, cache, collector, registry) {
|
|
280
427
|
const absoluteRepoPath = resolve(repoPath);
|
|
281
428
|
return {
|
|
282
429
|
repoPath: absoluteRepoPath,
|
|
@@ -292,12 +439,15 @@ function createRuleContext(repoPath, summary, cache, collector) {
|
|
|
292
439
|
return findMatchingPaths(absoluteRepoPath, pattern, true);
|
|
293
440
|
},
|
|
294
441
|
observe(observation) {
|
|
295
|
-
assertObservationInit(observation, "ctx.observe");
|
|
442
|
+
assertObservationInit(observation, "ctx.observe", registry);
|
|
296
443
|
collector.observations.push(observation);
|
|
297
444
|
},
|
|
298
445
|
finding(finding) {
|
|
299
446
|
assertFindingInput(finding, "ctx.finding");
|
|
300
|
-
collector.findings.push(
|
|
447
|
+
collector.findings.push({
|
|
448
|
+
finding: normalizeFindingInput(finding, collector.findings.length),
|
|
449
|
+
deduplicate: finding.deduplicate !== false,
|
|
450
|
+
});
|
|
301
451
|
},
|
|
302
452
|
review: {
|
|
303
453
|
assessment(assessment) {
|
|
@@ -306,11 +456,11 @@ function createRuleContext(repoPath, summary, cache, collector) {
|
|
|
306
456
|
},
|
|
307
457
|
positive(note) {
|
|
308
458
|
assertReviewNote(note, "ctx.review.positive");
|
|
309
|
-
collector.positives.push(note);
|
|
459
|
+
collector.positives.push(normalizeReviewNote(note));
|
|
310
460
|
},
|
|
311
461
|
observe(note) {
|
|
312
462
|
assertReviewNote(note, "ctx.review.observe");
|
|
313
|
-
collector.reviewObservations.push(note);
|
|
463
|
+
collector.reviewObservations.push(normalizeReviewNote(note));
|
|
314
464
|
},
|
|
315
465
|
score(score) {
|
|
316
466
|
assertReviewScore(score);
|
|
@@ -373,8 +523,11 @@ function createReviewCollector() {
|
|
|
373
523
|
}
|
|
374
524
|
function buildReviewResult(input) {
|
|
375
525
|
const thresholds = input.policy.confidenceThresholds ?? DEFAULT_CONFIDENCE_THRESHOLDS;
|
|
376
|
-
const
|
|
377
|
-
const allFindings = deduplicateFindings([
|
|
526
|
+
const synthesis = synthesizeObservationFindings(input.collector.observations, thresholds, input.registry);
|
|
527
|
+
const allFindings = deduplicateFindings([
|
|
528
|
+
...synthesis.findings.map((finding) => ({ finding, deduplicate: true })),
|
|
529
|
+
...input.collector.findings,
|
|
530
|
+
]).map((finding) => calibrateFindingSeverity(finding, input.policy));
|
|
378
531
|
const ranked = rankFindings(allFindings);
|
|
379
532
|
const minimumConfidence = input.policy.minimumConfidence ?? Confidence.Medium;
|
|
380
533
|
const includeInformational = input.policy.includeInformational ?? false;
|
|
@@ -393,7 +546,10 @@ function buildReviewResult(input) {
|
|
|
393
546
|
}
|
|
394
547
|
}
|
|
395
548
|
const positives = selectPositiveSignals(input.collector.positives);
|
|
396
|
-
const reviewObservations = deduplicateReviewObservations(
|
|
549
|
+
const reviewObservations = deduplicateReviewObservations([
|
|
550
|
+
...input.collector.reviewObservations,
|
|
551
|
+
...deduplicateScores(input.collector.scores).map(scoreToReviewNote),
|
|
552
|
+
], positives);
|
|
397
553
|
return omitUndefined({
|
|
398
554
|
adversary: input.adversary,
|
|
399
555
|
target: omitUndefined({
|
|
@@ -403,11 +559,10 @@ function buildReviewResult(input) {
|
|
|
403
559
|
assessment: input.collector.assessment ?? synthesizeAssessment(eligible, positives),
|
|
404
560
|
positives,
|
|
405
561
|
observations: reviewObservations,
|
|
406
|
-
scores: input.collector.scores.length > 0 ? deduplicateScores(input.collector.scores) : undefined,
|
|
407
562
|
findings: eligible,
|
|
408
563
|
opinion: input.collector.opinion ?? synthesizeOpinion(eligible),
|
|
409
564
|
suppressed: {
|
|
410
|
-
observations:
|
|
565
|
+
observations: synthesis.suppressedObservations,
|
|
411
566
|
findings: suppressedFindings.length,
|
|
412
567
|
},
|
|
413
568
|
timing: input.timing,
|
|
@@ -415,25 +570,27 @@ function buildReviewResult(input) {
|
|
|
415
570
|
rawObservations: input.includeRawObservations ? input.collector.observations : undefined,
|
|
416
571
|
});
|
|
417
572
|
}
|
|
418
|
-
function synthesizeObservationFindings(observations, thresholds) {
|
|
573
|
+
function synthesizeObservationFindings(observations, thresholds, registry) {
|
|
419
574
|
const grouped = new Map();
|
|
420
575
|
const seen = new Set();
|
|
576
|
+
let suppressedObservations = 0;
|
|
421
577
|
for (const observation of observations) {
|
|
422
|
-
const rule =
|
|
578
|
+
const rule = registry.lookup(observation.ruleId);
|
|
423
579
|
const groupKey = observation.groupKey ?? defaultObservationGroupKey(observation, rule);
|
|
424
580
|
const dedupeKey = stableStringify({ groupKey, observation });
|
|
425
581
|
if (observation.deduplicate !== false && seen.has(dedupeKey)) {
|
|
582
|
+
suppressedObservations += 1;
|
|
426
583
|
continue;
|
|
427
584
|
}
|
|
428
585
|
seen.add(dedupeKey);
|
|
429
586
|
grouped.set(groupKey, [...(grouped.get(groupKey) ?? []), observation]);
|
|
430
587
|
}
|
|
431
|
-
|
|
588
|
+
const findings = [...grouped.entries()].map(([groupKey, group]) => {
|
|
432
589
|
const first = group[0];
|
|
433
590
|
if (first === undefined) {
|
|
434
591
|
throw new Error("Cannot synthesize finding from an empty observation group.");
|
|
435
592
|
}
|
|
436
|
-
const rule =
|
|
593
|
+
const rule = registry.lookup(first.ruleId);
|
|
437
594
|
const synthesis = rule?.aggregate?.(group) ?? {};
|
|
438
595
|
const synthesisSource = rule?.aggregate === undefined ? "generic" : "rule";
|
|
439
596
|
log.debug(`review synthesis ruleId=${first.ruleId} groupKey=${groupKey} observations=${group.length} selected=${synthesisSource} fallback=${synthesisSource === "generic"}`);
|
|
@@ -443,7 +600,7 @@ function synthesizeObservationFindings(observations, thresholds) {
|
|
|
443
600
|
: normalizeParagraph(synthesis.recommendation);
|
|
444
601
|
const confidence = aggregateConfidence(group.map((observation) => normalizeConfidence(observation.confidence ?? rule?.defaultConfidence ?? Confidence.Medium, thresholds)), first.confidenceAggregation ?? "maximum");
|
|
445
602
|
const severity = aggregateSeverity(group.map((observation) => observation.severity ?? rule?.defaultSeverity ?? Severity.Info), first.severityAggregation ?? "highest");
|
|
446
|
-
|
|
603
|
+
const finding = {
|
|
447
604
|
id: synthesis.id ?? stableId(`${first.ruleId}:${groupKey}`),
|
|
448
605
|
ruleId: synthesis.ruleId ?? first.ruleId,
|
|
449
606
|
groupKey: synthesis.groupKey ?? groupKey,
|
|
@@ -461,15 +618,18 @@ function synthesizeObservationFindings(observations, thresholds) {
|
|
|
461
618
|
evidence,
|
|
462
619
|
recommendation: recommendation.length > 0 ? recommendation : undefined,
|
|
463
620
|
remediation: synthesis.remediation ?? first.remediation,
|
|
464
|
-
synthesisSource,
|
|
465
621
|
tags: synthesis.tags ?? uniqueStrings(group.flatMap((observation) => observation.tags ?? [])),
|
|
466
622
|
metadata: synthesis.metadata ?? first.metadata,
|
|
467
623
|
};
|
|
624
|
+
assertFindingInput(finding, `adversary aggregate rule "${first.ruleId}"`);
|
|
625
|
+
return finding;
|
|
468
626
|
});
|
|
627
|
+
return { findings, suppressedObservations };
|
|
469
628
|
}
|
|
470
|
-
function normalizeFindingInput(input) {
|
|
629
|
+
function normalizeFindingInput(input, occurrence = 0) {
|
|
471
630
|
return omitUndefined({
|
|
472
|
-
id: input.id ??
|
|
631
|
+
id: input.id ??
|
|
632
|
+
stableId(`${input.ruleId ?? input.title}:${input.groupKey ?? input.category}${input.deduplicate === false ? `:${occurrence}` : ""}`),
|
|
473
633
|
ruleId: input.ruleId,
|
|
474
634
|
groupKey: input.groupKey,
|
|
475
635
|
title: input.title,
|
|
@@ -489,7 +649,12 @@ function normalizeFindingInput(input) {
|
|
|
489
649
|
function deduplicateFindings(findings) {
|
|
490
650
|
const seen = new Set();
|
|
491
651
|
const result = [];
|
|
492
|
-
for (const
|
|
652
|
+
for (const collected of findings) {
|
|
653
|
+
const { finding } = collected;
|
|
654
|
+
if (!collected.deduplicate) {
|
|
655
|
+
result.push({ ...finding, evidence: deduplicateEvidence(finding.evidence) });
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
493
658
|
const key = finding.groupKey ?? finding.id;
|
|
494
659
|
if (seen.has(key)) {
|
|
495
660
|
const existing = result.find((item) => (item.groupKey ?? item.id) === key);
|
|
@@ -508,7 +673,7 @@ function deduplicateEvidence(evidence) {
|
|
|
508
673
|
const seen = new Set();
|
|
509
674
|
const result = [];
|
|
510
675
|
for (const item of evidence) {
|
|
511
|
-
const normalized =
|
|
676
|
+
const normalized = normalizeEvidence(item);
|
|
512
677
|
const key = stableStringify(normalized);
|
|
513
678
|
if (!seen.has(key)) {
|
|
514
679
|
seen.add(key);
|
|
@@ -516,17 +681,44 @@ function deduplicateEvidence(evidence) {
|
|
|
516
681
|
}
|
|
517
682
|
}
|
|
518
683
|
return result.sort((left, right) => {
|
|
519
|
-
const fileComparison = compareStrings(left.file ?? "", right.file ?? "");
|
|
684
|
+
const fileComparison = compareStrings(left.location?.file ?? "", right.location?.file ?? "");
|
|
520
685
|
if (fileComparison !== 0) {
|
|
521
686
|
return fileComparison;
|
|
522
687
|
}
|
|
523
|
-
const lineComparison = compareNumbers(left.line, right.line);
|
|
688
|
+
const lineComparison = compareNumbers(left.location?.line, right.location?.line);
|
|
524
689
|
if (lineComparison !== 0) {
|
|
525
690
|
return lineComparison;
|
|
526
691
|
}
|
|
527
692
|
return compareStrings(left.message ?? "", right.message ?? "");
|
|
528
693
|
});
|
|
529
694
|
}
|
|
695
|
+
function normalizeEvidence(input) {
|
|
696
|
+
const legacy = input;
|
|
697
|
+
const hasLocation = legacy.location !== undefined ||
|
|
698
|
+
legacy.file !== undefined ||
|
|
699
|
+
legacy.line !== undefined ||
|
|
700
|
+
legacy.endLine !== undefined;
|
|
701
|
+
const location = hasLocation
|
|
702
|
+
? omitUndefined({
|
|
703
|
+
file: legacy.location?.file ?? legacy.file,
|
|
704
|
+
line: legacy.location?.line ?? legacy.line,
|
|
705
|
+
endLine: legacy.location?.endLine ?? legacy.endLine,
|
|
706
|
+
})
|
|
707
|
+
: undefined;
|
|
708
|
+
return omitUndefined({
|
|
709
|
+
location,
|
|
710
|
+
label: input.label,
|
|
711
|
+
message: input.message,
|
|
712
|
+
snippet: input.snippet,
|
|
713
|
+
data: input.data ?? legacy.metadata,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
function normalizeReviewNote(note) {
|
|
717
|
+
return omitUndefined({
|
|
718
|
+
...note,
|
|
719
|
+
evidence: note.evidence === undefined ? undefined : deduplicateEvidence(note.evidence),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
530
722
|
function deduplicateNotes(notes) {
|
|
531
723
|
const seen = new Set();
|
|
532
724
|
const result = [];
|
|
@@ -571,15 +763,10 @@ function notesDescribeSameFact(positive, observation) {
|
|
|
571
763
|
}
|
|
572
764
|
const positiveText = normalizeSemanticText(`${positive.key} ${positive.summary}`);
|
|
573
765
|
const observationText = normalizeSemanticText(`${observation.key} ${observation.summary}`);
|
|
574
|
-
|
|
575
|
-
return true;
|
|
576
|
-
}
|
|
577
|
-
const positiveSignals = highSignalSemanticTokens(positiveText);
|
|
578
|
-
const observationSignals = highSignalSemanticTokens(observationText);
|
|
579
|
-
return positiveSignals.some((token) => observationSignals.includes(token));
|
|
766
|
+
return positiveText.length > 0 && positiveText === observationText;
|
|
580
767
|
}
|
|
581
768
|
function synthesizeAssessment(findings, positives = []) {
|
|
582
|
-
const strength = assessmentStrength(positives[0]
|
|
769
|
+
const strength = assessmentStrength(positives[0]);
|
|
583
770
|
if (findings.length === 0) {
|
|
584
771
|
return {
|
|
585
772
|
risk: "none",
|
|
@@ -600,13 +787,10 @@ function synthesizeAssessment(findings, positives = []) {
|
|
|
600
787
|
summary: joinSentences(strength, `${numberWord(findings.length)} material concerns were identified. The highest-value improvement is ${primaryConcern}.`),
|
|
601
788
|
};
|
|
602
789
|
}
|
|
603
|
-
function assessmentStrength(positive
|
|
790
|
+
function assessmentStrength(positive) {
|
|
604
791
|
if (positive === undefined) {
|
|
605
792
|
return undefined;
|
|
606
793
|
}
|
|
607
|
-
if (findingsReferenceDockerfile(findings)) {
|
|
608
|
-
return "This is a well-structured production Dockerfile.";
|
|
609
|
-
}
|
|
610
794
|
const summary = normalizeParagraph(positive.summary);
|
|
611
795
|
if (/^uses\b/i.test(summary)) {
|
|
612
796
|
return `The repository ${lowercaseFirst(summary)}`;
|
|
@@ -614,12 +798,6 @@ function assessmentStrength(positive, findings) {
|
|
|
614
798
|
return summary;
|
|
615
799
|
}
|
|
616
800
|
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
801
|
const summary = normalizeParagraph(finding.summary).split(/(?<=[.!?])\s+/, 1)[0];
|
|
624
802
|
return concernClause(lowercaseFirst(trimTrailingSentencePunctuation(summary ?? findingConcern(finding))));
|
|
625
803
|
}
|
|
@@ -643,7 +821,6 @@ function synthesizeOpinion(findings) {
|
|
|
643
821
|
}
|
|
644
822
|
const highestSeverity = highestFindingSeverity(findings);
|
|
645
823
|
const ship = severityWeight(highestSeverity) < severityWeight(Severity.High);
|
|
646
|
-
const subject = findingsReferenceDockerfile(findings) ? "this Dockerfile" : "this";
|
|
647
824
|
if (findings.length > 1) {
|
|
648
825
|
return {
|
|
649
826
|
ship,
|
|
@@ -655,16 +832,10 @@ function synthesizeOpinion(findings) {
|
|
|
655
832
|
return {
|
|
656
833
|
ship,
|
|
657
834
|
summary: ship
|
|
658
|
-
? `I would ship
|
|
835
|
+
? `I would ship this as-is. ${improvement} is the only improvement I would recommend before production.`
|
|
659
836
|
: `${improvement} is the most important improvement to address before production.`,
|
|
660
837
|
};
|
|
661
838
|
}
|
|
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
839
|
function deduplicateScores(scores) {
|
|
669
840
|
const seen = new Set();
|
|
670
841
|
const result = [];
|
|
@@ -676,8 +847,27 @@ function deduplicateScores(scores) {
|
|
|
676
847
|
}
|
|
677
848
|
return result.sort((left, right) => compareStrings(left.key, right.key));
|
|
678
849
|
}
|
|
850
|
+
function scoreToReviewNote(score) {
|
|
851
|
+
return {
|
|
852
|
+
key: `score.${score.key}`,
|
|
853
|
+
summary: formatScore(score),
|
|
854
|
+
metadata: {
|
|
855
|
+
kind: "score",
|
|
856
|
+
score: omitUndefined({
|
|
857
|
+
key: score.key,
|
|
858
|
+
label: score.label,
|
|
859
|
+
score: score.score,
|
|
860
|
+
max: score.max,
|
|
861
|
+
summary: score.summary,
|
|
862
|
+
}),
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
function isScoreReviewNote(note) {
|
|
867
|
+
return note.metadata?.kind === "score" && isRecord(note.metadata.score);
|
|
868
|
+
}
|
|
679
869
|
function observationToEvidence(observation) {
|
|
680
|
-
const
|
|
870
|
+
const data = isRecord(observation.evidence)
|
|
681
871
|
? observation.evidence
|
|
682
872
|
: observation.evidence === undefined
|
|
683
873
|
? undefined
|
|
@@ -690,19 +880,11 @@ function observationToEvidence(observation) {
|
|
|
690
880
|
stringFromUnknown(observation.evidence.instruction))
|
|
691
881
|
: observation.location?.snippet;
|
|
692
882
|
return omitUndefined({
|
|
693
|
-
location: observation.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,
|
|
883
|
+
location: normalizeEvidence(observation.location ?? {}).location,
|
|
701
884
|
label: observation.location?.label ?? message,
|
|
702
885
|
message: observation.location?.message ?? message,
|
|
703
886
|
snippet,
|
|
704
|
-
data
|
|
705
|
-
metadata,
|
|
887
|
+
data,
|
|
706
888
|
});
|
|
707
889
|
}
|
|
708
890
|
function structuredEvidenceMessage(evidence) {
|
|
@@ -710,10 +892,6 @@ function structuredEvidenceMessage(evidence) {
|
|
|
710
892
|
if (explicitMessage !== undefined) {
|
|
711
893
|
return explicitMessage;
|
|
712
894
|
}
|
|
713
|
-
const stage = stringFromUnknown(evidence.stage);
|
|
714
|
-
if (stage !== undefined) {
|
|
715
|
-
return `${stage} stage`;
|
|
716
|
-
}
|
|
717
895
|
const label = stringFromUnknown(evidence.label) ?? stringFromUnknown(evidence.name);
|
|
718
896
|
if (label !== undefined) {
|
|
719
897
|
return label;
|
|
@@ -789,7 +967,6 @@ function renderObservationTemplate(template, group) {
|
|
|
789
967
|
function observationTemplateValues(group) {
|
|
790
968
|
const first = group[0];
|
|
791
969
|
const subjects = uniqueStrings(group.map((observation) => observation.subject));
|
|
792
|
-
const stages = uniqueStrings(group.map(extractStage).filter(isNonEmptyString));
|
|
793
970
|
const locations = uniqueStrings(group.map(formatObservationLocation).filter(isNonEmptyString));
|
|
794
971
|
return omitUndefined({
|
|
795
972
|
count: numberWord(group.length),
|
|
@@ -797,8 +974,6 @@ function observationTemplateValues(group) {
|
|
|
797
974
|
locations: joinHumanList(locations),
|
|
798
975
|
subject: first?.subject,
|
|
799
976
|
subjects: joinHumanList(subjects),
|
|
800
|
-
stage: stages[0],
|
|
801
|
-
stages: joinHumanList(stages),
|
|
802
977
|
});
|
|
803
978
|
}
|
|
804
979
|
function observationValue(observation, field) {
|
|
@@ -809,19 +984,6 @@ function observationValue(observation, field) {
|
|
|
809
984
|
}
|
|
810
985
|
return observation[field];
|
|
811
986
|
}
|
|
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
987
|
function formatObservationLocation(observation) {
|
|
826
988
|
if (observation.location?.file === undefined) {
|
|
827
989
|
return undefined;
|
|
@@ -862,10 +1024,6 @@ function recommendationSubject(recommendation) {
|
|
|
862
1024
|
return undefined;
|
|
863
1025
|
}
|
|
864
1026
|
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
1027
|
const firstClause = normalized.split(/\s+(?:and|when|where|with)\s+/i)[0]?.trim();
|
|
870
1028
|
if (!isNonEmptyString(firstClause)) {
|
|
871
1029
|
return undefined;
|
|
@@ -883,6 +1041,7 @@ function gerundPhrase(phrase) {
|
|
|
883
1041
|
function toGerund(verb) {
|
|
884
1042
|
const lower = verb.toLowerCase();
|
|
885
1043
|
const irregular = {
|
|
1044
|
+
pin: "pinning",
|
|
886
1045
|
run: "running",
|
|
887
1046
|
use: "using",
|
|
888
1047
|
};
|
|
@@ -895,21 +1054,6 @@ function toGerund(verb) {
|
|
|
895
1054
|
}
|
|
896
1055
|
return `${lower}ing`;
|
|
897
1056
|
}
|
|
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
1057
|
function normalizeSemanticText(value) {
|
|
914
1058
|
return value
|
|
915
1059
|
.toLowerCase()
|
|
@@ -947,12 +1091,10 @@ function calibrateFindingSeverity(finding, policy) {
|
|
|
947
1091
|
}
|
|
948
1092
|
function scoreFinding(finding) {
|
|
949
1093
|
const locationScore = Math.min(finding.evidence.length, 5) * 3;
|
|
950
|
-
const runtimeScore = finding.tags?.some((tag) => ["production", "runtime"].includes(tag)) ? 8 : 0;
|
|
951
1094
|
const remediationScore = finding.remediation?.complexity === "trivial" ? 3 : 0;
|
|
952
1095
|
return (severityWeight(finding.severity) * 10 +
|
|
953
1096
|
confidenceWeight(finding.confidence) * 12 +
|
|
954
1097
|
locationScore +
|
|
955
|
-
runtimeScore +
|
|
956
1098
|
remediationScore);
|
|
957
1099
|
}
|
|
958
1100
|
function severityWeight(severity) {
|
|
@@ -1034,11 +1176,11 @@ function stableStringify(value) {
|
|
|
1034
1176
|
function uniqueStrings(values) {
|
|
1035
1177
|
return [...new Set(values.filter(isNonEmptyString))].sort(compareStrings);
|
|
1036
1178
|
}
|
|
1037
|
-
function assertObservationInit(value, source) {
|
|
1179
|
+
function assertObservationInit(value, source, registry) {
|
|
1038
1180
|
requireString(value.ruleId, `${source}.ruleId`);
|
|
1039
1181
|
requireString(value.subject, `${source}.subject`);
|
|
1040
1182
|
requireObservationTitle(value.title, `${source}.title`);
|
|
1041
|
-
const rule =
|
|
1183
|
+
const rule = registry.lookup(value.ruleId);
|
|
1042
1184
|
if (value.category === undefined && rule?.category === undefined) {
|
|
1043
1185
|
throw new Error(`${source}.category is required when rule.category is not defined.`);
|
|
1044
1186
|
}
|
|
@@ -1080,6 +1222,7 @@ function assertFindingInput(value, source) {
|
|
|
1080
1222
|
optionalString(value.ruleId, `${source}.ruleId`);
|
|
1081
1223
|
optionalString(value.groupKey, `${source}.groupKey`);
|
|
1082
1224
|
optionalStringArray(value.tags, `${source}.tags`);
|
|
1225
|
+
optionalRemediation(value.remediation, `${source}.remediation`);
|
|
1083
1226
|
}
|
|
1084
1227
|
function assertReviewNote(value, source) {
|
|
1085
1228
|
requireString(value.key, `${source}.key`);
|
|
@@ -1092,11 +1235,17 @@ function assertReviewNote(value, source) {
|
|
|
1092
1235
|
}
|
|
1093
1236
|
function assertReviewScore(value) {
|
|
1094
1237
|
requireString(value.key, "ctx.review.score.key");
|
|
1095
|
-
if (typeof value.score !== "number" || Number.
|
|
1096
|
-
throw new Error("ctx.review.score.score must be a number.");
|
|
1238
|
+
if (typeof value.score !== "number" || !Number.isFinite(value.score)) {
|
|
1239
|
+
throw new Error("ctx.review.score.score must be a finite number.");
|
|
1240
|
+
}
|
|
1241
|
+
if (value.max !== undefined && (typeof value.max !== "number" || !Number.isFinite(value.max))) {
|
|
1242
|
+
throw new Error("ctx.review.score.max must be a finite number.");
|
|
1097
1243
|
}
|
|
1098
|
-
if (value.
|
|
1099
|
-
throw new Error("ctx.review.score.
|
|
1244
|
+
if (value.score < 0) {
|
|
1245
|
+
throw new Error("ctx.review.score.score must be greater than or equal to zero.");
|
|
1246
|
+
}
|
|
1247
|
+
if (value.max !== undefined && (value.max <= 0 || value.score > value.max)) {
|
|
1248
|
+
throw new Error("ctx.review.score.max must be positive and no smaller than score.");
|
|
1100
1249
|
}
|
|
1101
1250
|
optionalString(value.label, "ctx.review.score.label");
|
|
1102
1251
|
optionalString(value.summary, "ctx.review.score.summary");
|
|
@@ -1124,6 +1273,37 @@ function assertRuleDefinition(rule) {
|
|
|
1124
1273
|
throw new Error("rule.aggregate must be a function.");
|
|
1125
1274
|
}
|
|
1126
1275
|
}
|
|
1276
|
+
function assertReviewPolicy(policy, source) {
|
|
1277
|
+
if (policy.minimumConfidence !== undefined && !isConfidence(policy.minimumConfidence)) {
|
|
1278
|
+
throw new Error(`${source}.minimumConfidence must be one of low, medium, high.`);
|
|
1279
|
+
}
|
|
1280
|
+
if (policy.maximumFindings !== undefined &&
|
|
1281
|
+
(!Number.isInteger(policy.maximumFindings) || policy.maximumFindings < 0)) {
|
|
1282
|
+
throw new Error(`${source}.maximumFindings must be a non-negative integer.`);
|
|
1283
|
+
}
|
|
1284
|
+
if (policy.confidenceThresholds !== undefined) {
|
|
1285
|
+
const { medium, high } = policy.confidenceThresholds;
|
|
1286
|
+
if (medium < 0 || high > 1 || medium > high) {
|
|
1287
|
+
throw new Error(`${source}.confidenceThresholds must satisfy 0 <= medium <= high <= 1.`);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
for (const [ruleId, severity] of Object.entries(policy.severityOverrides ?? {})) {
|
|
1291
|
+
if (!isSeverity(severity)) {
|
|
1292
|
+
throw new Error(`${source}.severityOverrides["${ruleId}"] is not a valid severity.`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
function optionalRemediation(value, field) {
|
|
1297
|
+
if (value === undefined)
|
|
1298
|
+
return;
|
|
1299
|
+
if (!isRecord(value))
|
|
1300
|
+
throw new Error(`${field} must be an object.`);
|
|
1301
|
+
if (value.complexity !== undefined &&
|
|
1302
|
+
(typeof value.complexity !== "string" ||
|
|
1303
|
+
!["trivial", "small", "medium", "large", "architectural"].includes(value.complexity))) {
|
|
1304
|
+
throw new Error(`${field}.complexity is invalid.`);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1127
1307
|
function requireObservationTitle(value, field) {
|
|
1128
1308
|
if (typeof value === "string") {
|
|
1129
1309
|
requireString(value, field);
|
|
@@ -1156,12 +1336,35 @@ function optionalEvidence(value, field) {
|
|
|
1156
1336
|
if (!isRecord(value)) {
|
|
1157
1337
|
throw new Error(`${field} must be an object.`);
|
|
1158
1338
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
optionalPositiveInteger(
|
|
1339
|
+
const input = value;
|
|
1340
|
+
optionalString(input.file, `${field}.file`);
|
|
1341
|
+
optionalPositiveInteger(input.line, `${field}.line`);
|
|
1342
|
+
optionalPositiveInteger(input.endLine, `${field}.endLine`);
|
|
1162
1343
|
optionalString(value.message, `${field}.message`);
|
|
1163
1344
|
optionalString(value.snippet, `${field}.snippet`);
|
|
1164
1345
|
optionalString(value.label, `${field}.label`);
|
|
1346
|
+
if (value.location !== undefined) {
|
|
1347
|
+
if (!isRecord(value.location)) {
|
|
1348
|
+
throw new Error(`${field}.location must be an object.`);
|
|
1349
|
+
}
|
|
1350
|
+
optionalString(value.location.file, `${field}.location.file`);
|
|
1351
|
+
optionalPositiveInteger(value.location.line, `${field}.location.line`);
|
|
1352
|
+
optionalPositiveInteger(value.location.endLine, `${field}.location.endLine`);
|
|
1353
|
+
}
|
|
1354
|
+
const line = value.location?.line ?? input.line;
|
|
1355
|
+
const endLine = value.location?.endLine ?? input.endLine;
|
|
1356
|
+
if (endLine !== undefined && line === undefined) {
|
|
1357
|
+
throw new Error(`${field}.endLine requires line.`);
|
|
1358
|
+
}
|
|
1359
|
+
if (endLine !== undefined && line !== undefined && endLine < line) {
|
|
1360
|
+
throw new Error(`${field}.endLine must not precede line.`);
|
|
1361
|
+
}
|
|
1362
|
+
if (value.data !== undefined && !isRecord(value.data)) {
|
|
1363
|
+
throw new Error(`${field}.data must be an object.`);
|
|
1364
|
+
}
|
|
1365
|
+
if (input.metadata !== undefined && !isRecord(input.metadata)) {
|
|
1366
|
+
throw new Error(`${field}.metadata must be an object.`);
|
|
1367
|
+
}
|
|
1165
1368
|
}
|
|
1166
1369
|
function writeLog(level, message) {
|
|
1167
1370
|
process.stderr.write(`[adversary] ${level}: ${String(message)}\n`);
|
|
@@ -1245,18 +1448,10 @@ const semanticStopWords = new Set([
|
|
|
1245
1448
|
"uses",
|
|
1246
1449
|
"using",
|
|
1247
1450
|
]);
|
|
1248
|
-
const highSignalReviewTerms = new Set([
|
|
1249
|
-
"artifact",
|
|
1250
|
-
"builder",
|
|
1251
|
-
"digest",
|
|
1252
|
-
"multi",
|
|
1253
|
-
"runtime",
|
|
1254
|
-
"stage",
|
|
1255
|
-
]);
|
|
1256
1451
|
function formatEvidenceLocation(evidence) {
|
|
1257
|
-
const file = evidence.location?.file
|
|
1258
|
-
const line = evidence.location?.line
|
|
1259
|
-
const endLine = evidence.location?.endLine
|
|
1452
|
+
const file = evidence.location?.file;
|
|
1453
|
+
const line = evidence.location?.line;
|
|
1454
|
+
const endLine = evidence.location?.endLine;
|
|
1260
1455
|
if (file === undefined) {
|
|
1261
1456
|
return "";
|
|
1262
1457
|
}
|