@adversarylabs/sdk 0.1.3 → 0.1.4
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 +28 -19
- package/dist/index.d.ts +48 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +222 -132
- package/dist/index.js.map +1 -1
- package/package.json +18 -5
- package/schemas/adversary.run.v1.schema.json +275 -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 +1309 -0
- package/templates/basic/package.json +4 -4
- package/templates/basic/src/index.ts +8 -1
- package/templates/basic/test/index.test.ts +0 -4
- package/schemas/adversary.findings.v1.schema.json +0 -80
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
|
3
3
|
export const DEFAULT_INPUT_PATH = "/adversary/input.json";
|
|
4
4
|
export const DEFAULT_OUTPUT_PATH = "/adversary/output.json";
|
|
5
5
|
export const ADVERSARY_RUN_PROTOCOL_VERSION = 1;
|
|
6
|
+
export const REVIEW_RESULT_SCHEMA_VERSION = "adversary.review.v1";
|
|
6
7
|
const verboseValues = new Set(["1", "true", "TRUE", "yes", "YES"]);
|
|
7
8
|
export const Severity = {
|
|
8
9
|
Info: "info",
|
|
@@ -46,19 +47,63 @@ export class RuleRegistry {
|
|
|
46
47
|
rules = new Map();
|
|
47
48
|
register(rule) {
|
|
48
49
|
assertRuleDefinition(rule);
|
|
49
|
-
this.rules.
|
|
50
|
+
if (this.rules.has(rule.id)) {
|
|
51
|
+
throw new Error(`Rule definition "${rule.id}" is already registered.`);
|
|
52
|
+
}
|
|
53
|
+
this.rules.set(rule.id, cloneRuleDefinition(rule));
|
|
54
|
+
}
|
|
55
|
+
replace(rule) {
|
|
56
|
+
assertRuleDefinition(rule);
|
|
57
|
+
if (!this.rules.has(rule.id)) {
|
|
58
|
+
throw new Error(`Rule definition "${rule.id}" is not registered.`);
|
|
59
|
+
}
|
|
60
|
+
this.rules.set(rule.id, cloneRuleDefinition(rule));
|
|
50
61
|
}
|
|
51
62
|
lookup(ruleId) {
|
|
52
|
-
|
|
63
|
+
const rule = this.rules.get(ruleId);
|
|
64
|
+
return rule === undefined ? undefined : cloneRuleDefinition(rule);
|
|
53
65
|
}
|
|
54
66
|
has(ruleId) {
|
|
55
67
|
return this.rules.has(ruleId);
|
|
56
68
|
}
|
|
69
|
+
snapshot() {
|
|
70
|
+
const snapshot = new RuleRegistry();
|
|
71
|
+
for (const rule of this.rules.values()) {
|
|
72
|
+
snapshot.register(rule);
|
|
73
|
+
}
|
|
74
|
+
return snapshot;
|
|
75
|
+
}
|
|
76
|
+
importMissing(source) {
|
|
77
|
+
for (const rule of source.rules.values()) {
|
|
78
|
+
if (!this.rules.has(rule.id)) {
|
|
79
|
+
this.rules.set(rule.id, cloneRuleDefinition(rule));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
57
83
|
}
|
|
84
|
+
function cloneRuleDefinition(rule) {
|
|
85
|
+
return {
|
|
86
|
+
...rule,
|
|
87
|
+
groupBy: rule.groupBy === undefined ? undefined : [...rule.groupBy],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function cloneReviewPolicy(policy) {
|
|
91
|
+
return {
|
|
92
|
+
...policy,
|
|
93
|
+
confidenceThresholds: policy.confidenceThresholds === undefined ? undefined : { ...policy.confidenceThresholds },
|
|
94
|
+
severityOverrides: policy.severityOverrides === undefined ? undefined : { ...policy.severityOverrides },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** @deprecated Prefer app.defineRule(...) so definitions remain instance-scoped. */
|
|
58
98
|
export const ruleRegistry = new RuleRegistry();
|
|
99
|
+
/** @deprecated Prefer app.defineRule(...) so definitions remain instance-scoped. */
|
|
59
100
|
export function defineRule(rule) {
|
|
60
101
|
ruleRegistry.register(rule);
|
|
61
102
|
}
|
|
103
|
+
/** @deprecated Prefer app.replaceRule(...) so definitions remain instance-scoped. */
|
|
104
|
+
export function replaceRule(rule) {
|
|
105
|
+
ruleRegistry.replace(rule);
|
|
106
|
+
}
|
|
62
107
|
export const log = {
|
|
63
108
|
debug(message) {
|
|
64
109
|
if (isVerbose()) {
|
|
@@ -82,29 +127,48 @@ export class Adversary {
|
|
|
82
127
|
version;
|
|
83
128
|
rules = [];
|
|
84
129
|
reviewPolicy;
|
|
130
|
+
ruleDefinitions;
|
|
85
131
|
constructor(options) {
|
|
86
132
|
if (options.name.length === 0) {
|
|
87
133
|
throw new Error("Adversary name must be a non-empty string.");
|
|
88
134
|
}
|
|
89
135
|
this.name = options.name;
|
|
90
136
|
this.version = options.version;
|
|
91
|
-
this.reviewPolicy = options.review ?? {};
|
|
137
|
+
this.reviewPolicy = cloneReviewPolicy(options.review ?? {});
|
|
138
|
+
assertReviewPolicy(this.reviewPolicy, `adversary "${this.name}" review policy`);
|
|
139
|
+
this.ruleDefinitions = ruleRegistry.snapshot();
|
|
140
|
+
}
|
|
141
|
+
defineRule(rule) {
|
|
142
|
+
this.ruleDefinitions.register(rule);
|
|
143
|
+
}
|
|
144
|
+
replaceRule(rule) {
|
|
145
|
+
this.ruleDefinitions.replace(rule);
|
|
146
|
+
}
|
|
147
|
+
hasRuleDefinition(ruleId) {
|
|
148
|
+
return this.ruleDefinitions.has(ruleId);
|
|
92
149
|
}
|
|
93
150
|
rule(id, handler) {
|
|
94
151
|
if (id.length === 0) {
|
|
95
152
|
throw new Error("Rule id must be a non-empty string.");
|
|
96
153
|
}
|
|
154
|
+
if (this.rules.some((rule) => rule.id === id)) {
|
|
155
|
+
throw new Error(`App rule "${id}" is already registered.`);
|
|
156
|
+
}
|
|
157
|
+
// Compatibility for definitions registered with the deprecated top-level API after
|
|
158
|
+
// this Adversary was constructed. Once copied, later global changes cannot affect it.
|
|
159
|
+
this.ruleDefinitions.importMissing(ruleRegistry);
|
|
97
160
|
this.rules.push({ id, handler });
|
|
98
161
|
}
|
|
99
|
-
async run(options
|
|
162
|
+
async run(options) {
|
|
100
163
|
const startedAt = performance.now();
|
|
101
|
-
|
|
102
|
-
const repoPath =
|
|
164
|
+
assertReviewPolicy(options.review ?? {}, `adversary "${this.name}" run review policy`);
|
|
165
|
+
const repoPath = options.input.source.path;
|
|
103
166
|
const summary = {};
|
|
104
167
|
const cache = new Map();
|
|
105
168
|
const collector = createReviewCollector();
|
|
106
|
-
const
|
|
107
|
-
const
|
|
169
|
+
const registry = this.ruleDefinitions.snapshot();
|
|
170
|
+
const context = createRuleContext(repoPath, summary, cache, collector, registry);
|
|
171
|
+
const includeSuppressed = options.includeSuppressed;
|
|
108
172
|
for (const rule of this.rules) {
|
|
109
173
|
log.debug(`running rule ${rule.id}`);
|
|
110
174
|
await rule.handler(context);
|
|
@@ -114,16 +178,32 @@ export class Adversary {
|
|
|
114
178
|
repository: repoPath,
|
|
115
179
|
filesScanned: typeof summary.files_scanned === "number" ? summary.files_scanned : undefined,
|
|
116
180
|
collector,
|
|
117
|
-
policy: { ...this.reviewPolicy, ...options.review },
|
|
181
|
+
policy: cloneReviewPolicy({ ...this.reviewPolicy, ...options.review }),
|
|
182
|
+
registry,
|
|
118
183
|
includeSuppressed,
|
|
119
184
|
includeRawObservations: options.includeRawObservations,
|
|
120
|
-
timing:
|
|
185
|
+
timing: options.includeTiming
|
|
186
|
+
? { totalMs: Math.round(performance.now() - startedAt) }
|
|
187
|
+
: undefined,
|
|
121
188
|
});
|
|
122
|
-
if (options.write !== false) {
|
|
123
|
-
await writeOutput(createAdversaryRunEnvelope(output), options.outputPath);
|
|
124
|
-
}
|
|
125
189
|
return output;
|
|
126
190
|
}
|
|
191
|
+
async runFromEnvironment(options = {}) {
|
|
192
|
+
const input = options.input ??
|
|
193
|
+
(await parseInput(options.inputPath ?? process.env.ADVERSARY_INPUT ?? DEFAULT_INPUT_PATH));
|
|
194
|
+
const repository = options.input
|
|
195
|
+
? input.source.path
|
|
196
|
+
: (process.env.ADVERSARY_REPO ?? input.source.path);
|
|
197
|
+
const result = await this.run({
|
|
198
|
+
input: { ...input, source: { ...input.source, path: repository } },
|
|
199
|
+
review: options.review,
|
|
200
|
+
includeSuppressed: options.includeSuppressed ?? parseBooleanEnv(process.env.ADVERSARY_INCLUDE_SUPPRESSED),
|
|
201
|
+
includeRawObservations: options.includeRawObservations,
|
|
202
|
+
includeTiming: options.includeTiming,
|
|
203
|
+
});
|
|
204
|
+
await writeOutput(createAdversaryRunEnvelope(result), options.outputPath ?? process.env.ADVERSARY_OUTPUT ?? DEFAULT_OUTPUT_PATH);
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
127
207
|
}
|
|
128
208
|
export function createAdversaryRunEnvelope(result) {
|
|
129
209
|
return {
|
|
@@ -131,7 +211,7 @@ export function createAdversaryRunEnvelope(result) {
|
|
|
131
211
|
result,
|
|
132
212
|
};
|
|
133
213
|
}
|
|
134
|
-
export async function parseInput(path =
|
|
214
|
+
export async function parseInput(path = DEFAULT_INPUT_PATH) {
|
|
135
215
|
const raw = await readFile(path, "utf8");
|
|
136
216
|
const parsed = JSON.parse(raw);
|
|
137
217
|
if (!isRecord(parsed)) {
|
|
@@ -145,7 +225,7 @@ export async function parseInput(path = process.env.ADVERSARY_INPUT ?? DEFAULT_I
|
|
|
145
225
|
}
|
|
146
226
|
return parsed;
|
|
147
227
|
}
|
|
148
|
-
export async function writeOutput(output, path =
|
|
228
|
+
export async function writeOutput(output, path = DEFAULT_OUTPUT_PATH) {
|
|
149
229
|
await mkdir(dirname(path), { recursive: true });
|
|
150
230
|
await writeFile(path, `${JSON.stringify(output, null, 2)}\n`, "utf8");
|
|
151
231
|
}
|
|
@@ -248,8 +328,8 @@ export class TerminalRenderer {
|
|
|
248
328
|
lines.push(`Findings: ${result.findings.length}`, "");
|
|
249
329
|
for (const finding of result.findings) {
|
|
250
330
|
lines.push(`[${finding.severity}] ${finding.title}`);
|
|
251
|
-
const firstEvidence = finding.evidence.find((item) => item.file !== undefined);
|
|
252
|
-
if (firstEvidence?.file !== undefined) {
|
|
331
|
+
const firstEvidence = finding.evidence.find((item) => item.location?.file !== undefined);
|
|
332
|
+
if (firstEvidence?.location?.file !== undefined) {
|
|
253
333
|
lines.push(formatEvidenceLocation(firstEvidence));
|
|
254
334
|
}
|
|
255
335
|
lines.push("");
|
|
@@ -276,7 +356,7 @@ export class TerminalRenderer {
|
|
|
276
356
|
this.write(`${lines.join("\n").trimEnd()}\n`);
|
|
277
357
|
}
|
|
278
358
|
}
|
|
279
|
-
function createRuleContext(repoPath, summary, cache, collector) {
|
|
359
|
+
function createRuleContext(repoPath, summary, cache, collector, registry) {
|
|
280
360
|
const absoluteRepoPath = resolve(repoPath);
|
|
281
361
|
return {
|
|
282
362
|
repoPath: absoluteRepoPath,
|
|
@@ -292,12 +372,15 @@ function createRuleContext(repoPath, summary, cache, collector) {
|
|
|
292
372
|
return findMatchingPaths(absoluteRepoPath, pattern, true);
|
|
293
373
|
},
|
|
294
374
|
observe(observation) {
|
|
295
|
-
assertObservationInit(observation, "ctx.observe");
|
|
375
|
+
assertObservationInit(observation, "ctx.observe", registry);
|
|
296
376
|
collector.observations.push(observation);
|
|
297
377
|
},
|
|
298
378
|
finding(finding) {
|
|
299
379
|
assertFindingInput(finding, "ctx.finding");
|
|
300
|
-
collector.findings.push(
|
|
380
|
+
collector.findings.push({
|
|
381
|
+
finding: normalizeFindingInput(finding, collector.findings.length),
|
|
382
|
+
deduplicate: finding.deduplicate !== false,
|
|
383
|
+
});
|
|
301
384
|
},
|
|
302
385
|
review: {
|
|
303
386
|
assessment(assessment) {
|
|
@@ -306,11 +389,11 @@ function createRuleContext(repoPath, summary, cache, collector) {
|
|
|
306
389
|
},
|
|
307
390
|
positive(note) {
|
|
308
391
|
assertReviewNote(note, "ctx.review.positive");
|
|
309
|
-
collector.positives.push(note);
|
|
392
|
+
collector.positives.push(normalizeReviewNote(note));
|
|
310
393
|
},
|
|
311
394
|
observe(note) {
|
|
312
395
|
assertReviewNote(note, "ctx.review.observe");
|
|
313
|
-
collector.reviewObservations.push(note);
|
|
396
|
+
collector.reviewObservations.push(normalizeReviewNote(note));
|
|
314
397
|
},
|
|
315
398
|
score(score) {
|
|
316
399
|
assertReviewScore(score);
|
|
@@ -373,8 +456,11 @@ function createReviewCollector() {
|
|
|
373
456
|
}
|
|
374
457
|
function buildReviewResult(input) {
|
|
375
458
|
const thresholds = input.policy.confidenceThresholds ?? DEFAULT_CONFIDENCE_THRESHOLDS;
|
|
376
|
-
const synthesized = synthesizeObservationFindings(input.collector.observations, thresholds);
|
|
377
|
-
const allFindings = deduplicateFindings([
|
|
459
|
+
const synthesized = synthesizeObservationFindings(input.collector.observations, thresholds, input.registry);
|
|
460
|
+
const allFindings = deduplicateFindings([
|
|
461
|
+
...synthesized.map((finding) => ({ finding, deduplicate: true })),
|
|
462
|
+
...input.collector.findings,
|
|
463
|
+
]).map((finding) => calibrateFindingSeverity(finding, input.policy));
|
|
378
464
|
const ranked = rankFindings(allFindings);
|
|
379
465
|
const minimumConfidence = input.policy.minimumConfidence ?? Confidence.Medium;
|
|
380
466
|
const includeInformational = input.policy.includeInformational ?? false;
|
|
@@ -395,6 +481,7 @@ function buildReviewResult(input) {
|
|
|
395
481
|
const positives = selectPositiveSignals(input.collector.positives);
|
|
396
482
|
const reviewObservations = deduplicateReviewObservations(input.collector.reviewObservations, positives);
|
|
397
483
|
return omitUndefined({
|
|
484
|
+
schemaVersion: REVIEW_RESULT_SCHEMA_VERSION,
|
|
398
485
|
adversary: input.adversary,
|
|
399
486
|
target: omitUndefined({
|
|
400
487
|
repository: input.repository,
|
|
@@ -407,7 +494,6 @@ function buildReviewResult(input) {
|
|
|
407
494
|
findings: eligible,
|
|
408
495
|
opinion: input.collector.opinion ?? synthesizeOpinion(eligible),
|
|
409
496
|
suppressed: {
|
|
410
|
-
observations: 0,
|
|
411
497
|
findings: suppressedFindings.length,
|
|
412
498
|
},
|
|
413
499
|
timing: input.timing,
|
|
@@ -415,11 +501,11 @@ function buildReviewResult(input) {
|
|
|
415
501
|
rawObservations: input.includeRawObservations ? input.collector.observations : undefined,
|
|
416
502
|
});
|
|
417
503
|
}
|
|
418
|
-
function synthesizeObservationFindings(observations, thresholds) {
|
|
504
|
+
function synthesizeObservationFindings(observations, thresholds, registry) {
|
|
419
505
|
const grouped = new Map();
|
|
420
506
|
const seen = new Set();
|
|
421
507
|
for (const observation of observations) {
|
|
422
|
-
const rule =
|
|
508
|
+
const rule = registry.lookup(observation.ruleId);
|
|
423
509
|
const groupKey = observation.groupKey ?? defaultObservationGroupKey(observation, rule);
|
|
424
510
|
const dedupeKey = stableStringify({ groupKey, observation });
|
|
425
511
|
if (observation.deduplicate !== false && seen.has(dedupeKey)) {
|
|
@@ -433,7 +519,7 @@ function synthesizeObservationFindings(observations, thresholds) {
|
|
|
433
519
|
if (first === undefined) {
|
|
434
520
|
throw new Error("Cannot synthesize finding from an empty observation group.");
|
|
435
521
|
}
|
|
436
|
-
const rule =
|
|
522
|
+
const rule = registry.lookup(first.ruleId);
|
|
437
523
|
const synthesis = rule?.aggregate?.(group) ?? {};
|
|
438
524
|
const synthesisSource = rule?.aggregate === undefined ? "generic" : "rule";
|
|
439
525
|
log.debug(`review synthesis ruleId=${first.ruleId} groupKey=${groupKey} observations=${group.length} selected=${synthesisSource} fallback=${synthesisSource === "generic"}`);
|
|
@@ -443,7 +529,7 @@ function synthesizeObservationFindings(observations, thresholds) {
|
|
|
443
529
|
: normalizeParagraph(synthesis.recommendation);
|
|
444
530
|
const confidence = aggregateConfidence(group.map((observation) => normalizeConfidence(observation.confidence ?? rule?.defaultConfidence ?? Confidence.Medium, thresholds)), first.confidenceAggregation ?? "maximum");
|
|
445
531
|
const severity = aggregateSeverity(group.map((observation) => observation.severity ?? rule?.defaultSeverity ?? Severity.Info), first.severityAggregation ?? "highest");
|
|
446
|
-
|
|
532
|
+
const finding = {
|
|
447
533
|
id: synthesis.id ?? stableId(`${first.ruleId}:${groupKey}`),
|
|
448
534
|
ruleId: synthesis.ruleId ?? first.ruleId,
|
|
449
535
|
groupKey: synthesis.groupKey ?? groupKey,
|
|
@@ -465,11 +551,14 @@ function synthesizeObservationFindings(observations, thresholds) {
|
|
|
465
551
|
tags: synthesis.tags ?? uniqueStrings(group.flatMap((observation) => observation.tags ?? [])),
|
|
466
552
|
metadata: synthesis.metadata ?? first.metadata,
|
|
467
553
|
};
|
|
554
|
+
assertFindingInput(finding, `adversary aggregate rule "${first.ruleId}"`);
|
|
555
|
+
return finding;
|
|
468
556
|
});
|
|
469
557
|
}
|
|
470
|
-
function normalizeFindingInput(input) {
|
|
558
|
+
function normalizeFindingInput(input, occurrence = 0) {
|
|
471
559
|
return omitUndefined({
|
|
472
|
-
id: input.id ??
|
|
560
|
+
id: input.id ??
|
|
561
|
+
stableId(`${input.ruleId ?? input.title}:${input.groupKey ?? input.category}${input.deduplicate === false ? `:${occurrence}` : ""}`),
|
|
473
562
|
ruleId: input.ruleId,
|
|
474
563
|
groupKey: input.groupKey,
|
|
475
564
|
title: input.title,
|
|
@@ -489,7 +578,12 @@ function normalizeFindingInput(input) {
|
|
|
489
578
|
function deduplicateFindings(findings) {
|
|
490
579
|
const seen = new Set();
|
|
491
580
|
const result = [];
|
|
492
|
-
for (const
|
|
581
|
+
for (const collected of findings) {
|
|
582
|
+
const { finding } = collected;
|
|
583
|
+
if (!collected.deduplicate) {
|
|
584
|
+
result.push({ ...finding, evidence: deduplicateEvidence(finding.evidence) });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
493
587
|
const key = finding.groupKey ?? finding.id;
|
|
494
588
|
if (seen.has(key)) {
|
|
495
589
|
const existing = result.find((item) => (item.groupKey ?? item.id) === key);
|
|
@@ -508,7 +602,7 @@ function deduplicateEvidence(evidence) {
|
|
|
508
602
|
const seen = new Set();
|
|
509
603
|
const result = [];
|
|
510
604
|
for (const item of evidence) {
|
|
511
|
-
const normalized =
|
|
605
|
+
const normalized = normalizeEvidence(item);
|
|
512
606
|
const key = stableStringify(normalized);
|
|
513
607
|
if (!seen.has(key)) {
|
|
514
608
|
seen.add(key);
|
|
@@ -516,17 +610,37 @@ function deduplicateEvidence(evidence) {
|
|
|
516
610
|
}
|
|
517
611
|
}
|
|
518
612
|
return result.sort((left, right) => {
|
|
519
|
-
const fileComparison = compareStrings(left.file ?? "", right.file ?? "");
|
|
613
|
+
const fileComparison = compareStrings(left.location?.file ?? "", right.location?.file ?? "");
|
|
520
614
|
if (fileComparison !== 0) {
|
|
521
615
|
return fileComparison;
|
|
522
616
|
}
|
|
523
|
-
const lineComparison = compareNumbers(left.line, right.line);
|
|
617
|
+
const lineComparison = compareNumbers(left.location?.line, right.location?.line);
|
|
524
618
|
if (lineComparison !== 0) {
|
|
525
619
|
return lineComparison;
|
|
526
620
|
}
|
|
527
621
|
return compareStrings(left.message ?? "", right.message ?? "");
|
|
528
622
|
});
|
|
529
623
|
}
|
|
624
|
+
function normalizeEvidence(input) {
|
|
625
|
+
const legacy = input;
|
|
626
|
+
const location = legacy.location ??
|
|
627
|
+
(legacy.file !== undefined || legacy.line !== undefined || legacy.endLine !== undefined
|
|
628
|
+
? omitUndefined({ file: legacy.file, line: legacy.line, endLine: legacy.endLine })
|
|
629
|
+
: undefined);
|
|
630
|
+
return omitUndefined({
|
|
631
|
+
location,
|
|
632
|
+
label: input.label,
|
|
633
|
+
message: input.message,
|
|
634
|
+
snippet: input.snippet,
|
|
635
|
+
data: input.data ?? legacy.metadata,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
function normalizeReviewNote(note) {
|
|
639
|
+
return omitUndefined({
|
|
640
|
+
...note,
|
|
641
|
+
evidence: note.evidence === undefined ? undefined : deduplicateEvidence(note.evidence),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
530
644
|
function deduplicateNotes(notes) {
|
|
531
645
|
const seen = new Set();
|
|
532
646
|
const result = [];
|
|
@@ -571,15 +685,10 @@ function notesDescribeSameFact(positive, observation) {
|
|
|
571
685
|
}
|
|
572
686
|
const positiveText = normalizeSemanticText(`${positive.key} ${positive.summary}`);
|
|
573
687
|
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));
|
|
688
|
+
return positiveText.length > 0 && positiveText === observationText;
|
|
580
689
|
}
|
|
581
690
|
function synthesizeAssessment(findings, positives = []) {
|
|
582
|
-
const strength = assessmentStrength(positives[0]
|
|
691
|
+
const strength = assessmentStrength(positives[0]);
|
|
583
692
|
if (findings.length === 0) {
|
|
584
693
|
return {
|
|
585
694
|
risk: "none",
|
|
@@ -600,13 +709,10 @@ function synthesizeAssessment(findings, positives = []) {
|
|
|
600
709
|
summary: joinSentences(strength, `${numberWord(findings.length)} material concerns were identified. The highest-value improvement is ${primaryConcern}.`),
|
|
601
710
|
};
|
|
602
711
|
}
|
|
603
|
-
function assessmentStrength(positive
|
|
712
|
+
function assessmentStrength(positive) {
|
|
604
713
|
if (positive === undefined) {
|
|
605
714
|
return undefined;
|
|
606
715
|
}
|
|
607
|
-
if (findingsReferenceDockerfile(findings)) {
|
|
608
|
-
return "This is a well-structured production Dockerfile.";
|
|
609
|
-
}
|
|
610
716
|
const summary = normalizeParagraph(positive.summary);
|
|
611
717
|
if (/^uses\b/i.test(summary)) {
|
|
612
718
|
return `The repository ${lowercaseFirst(summary)}`;
|
|
@@ -614,12 +720,6 @@ function assessmentStrength(positive, findings) {
|
|
|
614
720
|
return summary;
|
|
615
721
|
}
|
|
616
722
|
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
723
|
const summary = normalizeParagraph(finding.summary).split(/(?<=[.!?])\s+/, 1)[0];
|
|
624
724
|
return concernClause(lowercaseFirst(trimTrailingSentencePunctuation(summary ?? findingConcern(finding))));
|
|
625
725
|
}
|
|
@@ -643,7 +743,6 @@ function synthesizeOpinion(findings) {
|
|
|
643
743
|
}
|
|
644
744
|
const highestSeverity = highestFindingSeverity(findings);
|
|
645
745
|
const ship = severityWeight(highestSeverity) < severityWeight(Severity.High);
|
|
646
|
-
const subject = findingsReferenceDockerfile(findings) ? "this Dockerfile" : "this";
|
|
647
746
|
if (findings.length > 1) {
|
|
648
747
|
return {
|
|
649
748
|
ship,
|
|
@@ -655,16 +754,10 @@ function synthesizeOpinion(findings) {
|
|
|
655
754
|
return {
|
|
656
755
|
ship,
|
|
657
756
|
summary: ship
|
|
658
|
-
? `I would ship
|
|
757
|
+
? `I would ship this as-is. ${improvement} is the only improvement I would recommend before production.`
|
|
659
758
|
: `${improvement} is the most important improvement to address before production.`,
|
|
660
759
|
};
|
|
661
760
|
}
|
|
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
761
|
function deduplicateScores(scores) {
|
|
669
762
|
const seen = new Set();
|
|
670
763
|
const result = [];
|
|
@@ -677,7 +770,7 @@ function deduplicateScores(scores) {
|
|
|
677
770
|
return result.sort((left, right) => compareStrings(left.key, right.key));
|
|
678
771
|
}
|
|
679
772
|
function observationToEvidence(observation) {
|
|
680
|
-
const
|
|
773
|
+
const data = isRecord(observation.evidence)
|
|
681
774
|
? observation.evidence
|
|
682
775
|
: observation.evidence === undefined
|
|
683
776
|
? undefined
|
|
@@ -690,19 +783,11 @@ function observationToEvidence(observation) {
|
|
|
690
783
|
stringFromUnknown(observation.evidence.instruction))
|
|
691
784
|
: observation.location?.snippet;
|
|
692
785
|
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,
|
|
786
|
+
location: normalizeEvidence(observation.location ?? {}).location,
|
|
701
787
|
label: observation.location?.label ?? message,
|
|
702
788
|
message: observation.location?.message ?? message,
|
|
703
789
|
snippet,
|
|
704
|
-
data
|
|
705
|
-
metadata,
|
|
790
|
+
data,
|
|
706
791
|
});
|
|
707
792
|
}
|
|
708
793
|
function structuredEvidenceMessage(evidence) {
|
|
@@ -710,10 +795,6 @@ function structuredEvidenceMessage(evidence) {
|
|
|
710
795
|
if (explicitMessage !== undefined) {
|
|
711
796
|
return explicitMessage;
|
|
712
797
|
}
|
|
713
|
-
const stage = stringFromUnknown(evidence.stage);
|
|
714
|
-
if (stage !== undefined) {
|
|
715
|
-
return `${stage} stage`;
|
|
716
|
-
}
|
|
717
798
|
const label = stringFromUnknown(evidence.label) ?? stringFromUnknown(evidence.name);
|
|
718
799
|
if (label !== undefined) {
|
|
719
800
|
return label;
|
|
@@ -789,7 +870,6 @@ function renderObservationTemplate(template, group) {
|
|
|
789
870
|
function observationTemplateValues(group) {
|
|
790
871
|
const first = group[0];
|
|
791
872
|
const subjects = uniqueStrings(group.map((observation) => observation.subject));
|
|
792
|
-
const stages = uniqueStrings(group.map(extractStage).filter(isNonEmptyString));
|
|
793
873
|
const locations = uniqueStrings(group.map(formatObservationLocation).filter(isNonEmptyString));
|
|
794
874
|
return omitUndefined({
|
|
795
875
|
count: numberWord(group.length),
|
|
@@ -797,8 +877,6 @@ function observationTemplateValues(group) {
|
|
|
797
877
|
locations: joinHumanList(locations),
|
|
798
878
|
subject: first?.subject,
|
|
799
879
|
subjects: joinHumanList(subjects),
|
|
800
|
-
stage: stages[0],
|
|
801
|
-
stages: joinHumanList(stages),
|
|
802
880
|
});
|
|
803
881
|
}
|
|
804
882
|
function observationValue(observation, field) {
|
|
@@ -809,19 +887,6 @@ function observationValue(observation, field) {
|
|
|
809
887
|
}
|
|
810
888
|
return observation[field];
|
|
811
889
|
}
|
|
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
890
|
function formatObservationLocation(observation) {
|
|
826
891
|
if (observation.location?.file === undefined) {
|
|
827
892
|
return undefined;
|
|
@@ -862,10 +927,6 @@ function recommendationSubject(recommendation) {
|
|
|
862
927
|
return undefined;
|
|
863
928
|
}
|
|
864
929
|
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
930
|
const firstClause = normalized.split(/\s+(?:and|when|where|with)\s+/i)[0]?.trim();
|
|
870
931
|
if (!isNonEmptyString(firstClause)) {
|
|
871
932
|
return undefined;
|
|
@@ -883,6 +944,7 @@ function gerundPhrase(phrase) {
|
|
|
883
944
|
function toGerund(verb) {
|
|
884
945
|
const lower = verb.toLowerCase();
|
|
885
946
|
const irregular = {
|
|
947
|
+
pin: "pinning",
|
|
886
948
|
run: "running",
|
|
887
949
|
use: "using",
|
|
888
950
|
};
|
|
@@ -895,21 +957,6 @@ function toGerund(verb) {
|
|
|
895
957
|
}
|
|
896
958
|
return `${lower}ing`;
|
|
897
959
|
}
|
|
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
960
|
function normalizeSemanticText(value) {
|
|
914
961
|
return value
|
|
915
962
|
.toLowerCase()
|
|
@@ -947,12 +994,10 @@ function calibrateFindingSeverity(finding, policy) {
|
|
|
947
994
|
}
|
|
948
995
|
function scoreFinding(finding) {
|
|
949
996
|
const locationScore = Math.min(finding.evidence.length, 5) * 3;
|
|
950
|
-
const runtimeScore = finding.tags?.some((tag) => ["production", "runtime"].includes(tag)) ? 8 : 0;
|
|
951
997
|
const remediationScore = finding.remediation?.complexity === "trivial" ? 3 : 0;
|
|
952
998
|
return (severityWeight(finding.severity) * 10 +
|
|
953
999
|
confidenceWeight(finding.confidence) * 12 +
|
|
954
1000
|
locationScore +
|
|
955
|
-
runtimeScore +
|
|
956
1001
|
remediationScore);
|
|
957
1002
|
}
|
|
958
1003
|
function severityWeight(severity) {
|
|
@@ -1034,11 +1079,11 @@ function stableStringify(value) {
|
|
|
1034
1079
|
function uniqueStrings(values) {
|
|
1035
1080
|
return [...new Set(values.filter(isNonEmptyString))].sort(compareStrings);
|
|
1036
1081
|
}
|
|
1037
|
-
function assertObservationInit(value, source) {
|
|
1082
|
+
function assertObservationInit(value, source, registry) {
|
|
1038
1083
|
requireString(value.ruleId, `${source}.ruleId`);
|
|
1039
1084
|
requireString(value.subject, `${source}.subject`);
|
|
1040
1085
|
requireObservationTitle(value.title, `${source}.title`);
|
|
1041
|
-
const rule =
|
|
1086
|
+
const rule = registry.lookup(value.ruleId);
|
|
1042
1087
|
if (value.category === undefined && rule?.category === undefined) {
|
|
1043
1088
|
throw new Error(`${source}.category is required when rule.category is not defined.`);
|
|
1044
1089
|
}
|
|
@@ -1080,6 +1125,7 @@ function assertFindingInput(value, source) {
|
|
|
1080
1125
|
optionalString(value.ruleId, `${source}.ruleId`);
|
|
1081
1126
|
optionalString(value.groupKey, `${source}.groupKey`);
|
|
1082
1127
|
optionalStringArray(value.tags, `${source}.tags`);
|
|
1128
|
+
optionalRemediation(value.remediation, `${source}.remediation`);
|
|
1083
1129
|
}
|
|
1084
1130
|
function assertReviewNote(value, source) {
|
|
1085
1131
|
requireString(value.key, `${source}.key`);
|
|
@@ -1092,11 +1138,17 @@ function assertReviewNote(value, source) {
|
|
|
1092
1138
|
}
|
|
1093
1139
|
function assertReviewScore(value) {
|
|
1094
1140
|
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.");
|
|
1141
|
+
if (typeof value.score !== "number" || !Number.isFinite(value.score)) {
|
|
1142
|
+
throw new Error("ctx.review.score.score must be a finite number.");
|
|
1143
|
+
}
|
|
1144
|
+
if (value.max !== undefined && (typeof value.max !== "number" || !Number.isFinite(value.max))) {
|
|
1145
|
+
throw new Error("ctx.review.score.max must be a finite number.");
|
|
1097
1146
|
}
|
|
1098
|
-
if (value.
|
|
1099
|
-
throw new Error("ctx.review.score.
|
|
1147
|
+
if (value.score < 0) {
|
|
1148
|
+
throw new Error("ctx.review.score.score must be greater than or equal to zero.");
|
|
1149
|
+
}
|
|
1150
|
+
if (value.max !== undefined && (value.max <= 0 || value.score > value.max)) {
|
|
1151
|
+
throw new Error("ctx.review.score.max must be positive and no smaller than score.");
|
|
1100
1152
|
}
|
|
1101
1153
|
optionalString(value.label, "ctx.review.score.label");
|
|
1102
1154
|
optionalString(value.summary, "ctx.review.score.summary");
|
|
@@ -1124,6 +1176,37 @@ function assertRuleDefinition(rule) {
|
|
|
1124
1176
|
throw new Error("rule.aggregate must be a function.");
|
|
1125
1177
|
}
|
|
1126
1178
|
}
|
|
1179
|
+
function assertReviewPolicy(policy, source) {
|
|
1180
|
+
if (policy.minimumConfidence !== undefined && !isConfidence(policy.minimumConfidence)) {
|
|
1181
|
+
throw new Error(`${source}.minimumConfidence must be one of low, medium, high.`);
|
|
1182
|
+
}
|
|
1183
|
+
if (policy.maximumFindings !== undefined &&
|
|
1184
|
+
(!Number.isInteger(policy.maximumFindings) || policy.maximumFindings < 0)) {
|
|
1185
|
+
throw new Error(`${source}.maximumFindings must be a non-negative integer.`);
|
|
1186
|
+
}
|
|
1187
|
+
if (policy.confidenceThresholds !== undefined) {
|
|
1188
|
+
const { medium, high } = policy.confidenceThresholds;
|
|
1189
|
+
if (medium < 0 || high > 1 || medium > high) {
|
|
1190
|
+
throw new Error(`${source}.confidenceThresholds must satisfy 0 <= medium <= high <= 1.`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
for (const [ruleId, severity] of Object.entries(policy.severityOverrides ?? {})) {
|
|
1194
|
+
if (!isSeverity(severity)) {
|
|
1195
|
+
throw new Error(`${source}.severityOverrides["${ruleId}"] is not a valid severity.`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function optionalRemediation(value, field) {
|
|
1200
|
+
if (value === undefined)
|
|
1201
|
+
return;
|
|
1202
|
+
if (!isRecord(value))
|
|
1203
|
+
throw new Error(`${field} must be an object.`);
|
|
1204
|
+
if (value.complexity !== undefined &&
|
|
1205
|
+
(typeof value.complexity !== "string" ||
|
|
1206
|
+
!["trivial", "small", "medium", "large", "architectural"].includes(value.complexity))) {
|
|
1207
|
+
throw new Error(`${field}.complexity is invalid.`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1127
1210
|
function requireObservationTitle(value, field) {
|
|
1128
1211
|
if (typeof value === "string") {
|
|
1129
1212
|
requireString(value, field);
|
|
@@ -1156,12 +1239,27 @@ function optionalEvidence(value, field) {
|
|
|
1156
1239
|
if (!isRecord(value)) {
|
|
1157
1240
|
throw new Error(`${field} must be an object.`);
|
|
1158
1241
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
optionalPositiveInteger(
|
|
1242
|
+
const input = value;
|
|
1243
|
+
optionalString(input.file, `${field}.file`);
|
|
1244
|
+
optionalPositiveInteger(input.line, `${field}.line`);
|
|
1245
|
+
optionalPositiveInteger(input.endLine, `${field}.endLine`);
|
|
1162
1246
|
optionalString(value.message, `${field}.message`);
|
|
1163
1247
|
optionalString(value.snippet, `${field}.snippet`);
|
|
1164
1248
|
optionalString(value.label, `${field}.label`);
|
|
1249
|
+
if (value.location !== undefined) {
|
|
1250
|
+
if (!isRecord(value.location)) {
|
|
1251
|
+
throw new Error(`${field}.location must be an object.`);
|
|
1252
|
+
}
|
|
1253
|
+
optionalString(value.location.file, `${field}.location.file`);
|
|
1254
|
+
optionalPositiveInteger(value.location.line, `${field}.location.line`);
|
|
1255
|
+
optionalPositiveInteger(value.location.endLine, `${field}.location.endLine`);
|
|
1256
|
+
}
|
|
1257
|
+
if (value.data !== undefined && !isRecord(value.data)) {
|
|
1258
|
+
throw new Error(`${field}.data must be an object.`);
|
|
1259
|
+
}
|
|
1260
|
+
if (input.metadata !== undefined && !isRecord(input.metadata)) {
|
|
1261
|
+
throw new Error(`${field}.metadata must be an object.`);
|
|
1262
|
+
}
|
|
1165
1263
|
}
|
|
1166
1264
|
function writeLog(level, message) {
|
|
1167
1265
|
process.stderr.write(`[adversary] ${level}: ${String(message)}\n`);
|
|
@@ -1245,18 +1343,10 @@ const semanticStopWords = new Set([
|
|
|
1245
1343
|
"uses",
|
|
1246
1344
|
"using",
|
|
1247
1345
|
]);
|
|
1248
|
-
const highSignalReviewTerms = new Set([
|
|
1249
|
-
"artifact",
|
|
1250
|
-
"builder",
|
|
1251
|
-
"digest",
|
|
1252
|
-
"multi",
|
|
1253
|
-
"runtime",
|
|
1254
|
-
"stage",
|
|
1255
|
-
]);
|
|
1256
1346
|
function formatEvidenceLocation(evidence) {
|
|
1257
|
-
const file = evidence.location?.file
|
|
1258
|
-
const line = evidence.location?.line
|
|
1259
|
-
const endLine = evidence.location?.endLine
|
|
1347
|
+
const file = evidence.location?.file;
|
|
1348
|
+
const line = evidence.location?.line;
|
|
1349
|
+
const endLine = evidence.location?.endLine;
|
|
1260
1350
|
if (file === undefined) {
|
|
1261
1351
|
return "";
|
|
1262
1352
|
}
|