@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/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.set(rule.id, rule);
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
- return this.rules.get(ruleId);
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
- const input = options.input ?? (await parseInput(options.inputPath));
102
- const repoPath = process.env.ADVERSARY_REPO ?? input.source.path;
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 context = createRuleContext(repoPath, summary, cache, collector);
107
- const includeSuppressed = options.includeSuppressed ?? parseBooleanEnv(process.env.ADVERSARY_INCLUDE_SUPPRESSED);
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: { totalMs: Math.round(performance.now() - startedAt) },
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
- export async function parseInput(path = process.env.ADVERSARY_INPUT ?? DEFAULT_INPUT_PATH) {
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 = process.env.ADVERSARY_OUTPUT ?? DEFAULT_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
- if (result.scores !== undefined && result.scores.length > 0) {
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 score of result.scores) {
218
- lines.push(formatScore(score));
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 (result.observations.length > 0) {
376
+ if (additionalObservations.length > 0) {
230
377
  lines.push("Additional observations", "");
231
- for (const observation of result.observations) {
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(normalizeFindingInput(finding));
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 synthesized = synthesizeObservationFindings(input.collector.observations, thresholds);
377
- const allFindings = deduplicateFindings([...synthesized, ...input.collector.findings]).map((finding) => calibrateFindingSeverity(finding, input.policy));
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(input.collector.reviewObservations, positives);
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: 0,
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 = ruleRegistry.lookup(observation.ruleId);
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
- return [...grouped.entries()].map(([groupKey, group]) => {
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 = ruleRegistry.lookup(first.ruleId);
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
- return {
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 ?? stableId(`${input.ruleId ?? input.title}:${input.groupKey ?? input.category}`),
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 finding of findings) {
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 = omitUndefined(item);
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
- 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));
766
+ return positiveText.length > 0 && positiveText === observationText;
580
767
  }
581
768
  function synthesizeAssessment(findings, positives = []) {
582
- const strength = assessmentStrength(positives[0], findings);
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, findings) {
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 ${subject} as-is. ${improvement} is the only improvement I would recommend before production.`
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 metadata = isRecord(observation.evidence)
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?.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: metadata,
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 = ruleRegistry.lookup(value.ruleId);
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.isNaN(value.score)) {
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.max !== undefined && (typeof value.max !== "number" || Number.isNaN(value.max))) {
1099
- throw new Error("ctx.review.score.max must be a number.");
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
- optionalString(value.file, `${field}.file`);
1160
- optionalPositiveInteger(value.line, `${field}.line`);
1161
- optionalPositiveInteger(value.endLine, `${field}.endLine`);
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 ?? evidence.file;
1258
- const line = evidence.location?.line ?? evidence.line;
1259
- const endLine = evidence.location?.endLine ?? evidence.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
  }