feat(review): the security engine chosen and Claude Security converted (E 3.1)
lib/review/security-engine.mjs (decisions 175, 196, 219): - choose: Claude Security when available and base..head has committed changes; else /security-review when origin/HEAD resolves and origin/HEAD...HEAD is non-empty; else no engine (no-engine, no-origin, empty-diff), which is "not measured", never 0. A git failure or bad usage is exit 2, never a reason. - convert: reads the newest CLAUDE-SECURITY-<ts>/ report whose stamp is a changes scan of exactly base..head; confidence high becomes engine findings through toReviewerPayload, the rest go to below_threshold and never into findings. An unverified scan, missing researcher counts, fewer researchers returned than dispatched, an empty diff or scope, or a malformed record is exit 2. - NO_ENGINE_REVIEWER: with no engine chosen the expected reviewer security-engine never reports, so the verdict gate exits 2. ENGINES gains claude-security (its own reviewer and brief_ref, the engines' key) with a per-engine label for the written action; it is a security engine in isSecurityFinding, RAN_ENGINES, the coordinator contract (out-of-scope and brief-conflict rules) and the coordinator prose; engine:below-threshold is an unverified reason fed like engineOutOfScope. Pins of the changed coordinator prose follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d72d3667f6
commit
cb7c13b408
18 changed files with 1045 additions and 44 deletions
95
tests/helpers/claude-security-report.mjs
Normal file
95
tests/helpers/claude-security-report.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// tests/helpers/claude-security-report.mjs
|
||||
// A Claude Security report folder as a changes scan writes it (Claude Security
|
||||
// 0.12.0: scripts/render_report.py, scripts/lib/finding.py, scripts/lib/plugin.py),
|
||||
// for the tests of lib/review/security-engine.mjs `convert`:
|
||||
//
|
||||
// <dir>/CLAUDE-SECURITY-<UTC YYYYMMDD-HHMMSS>/
|
||||
// .gitignore `*`
|
||||
// CLAUDE-SECURITY-RESULTS.md
|
||||
// CLAUDE-SECURITY-RESULTS.jsonl one finding per line
|
||||
// CLAUDE-SECURITY-RESULTS.sarif
|
||||
// CLAUDE-SECURITY-REVISION-<first 12 hex of the commit>[-dirty].json the stamp
|
||||
//
|
||||
// `writeReport` takes the commit range and lets a test change any part of the
|
||||
// stamp (`stamp: (s) => ...`) or write the JSONL text itself (`jsonl`).
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** One JSONL record in the field order the products carry, with `over` applied. */
|
||||
export function record(over = {}) {
|
||||
return {
|
||||
id: 'F1',
|
||||
title: 'Shell command built from the branch name',
|
||||
impact: 'A pushed branch name runs a command on the machine that checks it out',
|
||||
file: 'src/run.mjs',
|
||||
line: 12,
|
||||
description: 'checkout() interpolates the branch name into a shell string.',
|
||||
exploit_scenario: 'A branch named `x;touch pwned` runs `touch pwned` when it is checked out',
|
||||
preconditions: ['the attacker can push a branch'],
|
||||
category: 'Injection',
|
||||
severity: 'HIGH',
|
||||
confidence: 'high',
|
||||
recommendation: 'Pass the branch name as an argv element through execFile, never through a shell string',
|
||||
cwe_id: 'CWE-78',
|
||||
snippet: 'exec(`git checkout ${branch}`)',
|
||||
symbol: 'checkout',
|
||||
declared_line: 12,
|
||||
via_change: null,
|
||||
other_cwe_ids: [],
|
||||
claudeSecurityPluginFindingId: 'a3f9c2d1e0b4',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** The stamp of a verified changes scan of `base..head`. */
|
||||
export function stampOf({ head, base, dirty = false, records = [] }) {
|
||||
const count = (sev) => records.filter((r) => r.severity === sev).length;
|
||||
return {
|
||||
generated_at: '2026-11-08T12:00:00+00:00',
|
||||
duration_s: 412,
|
||||
scan_id: '6f1d2a4e-5b7c-4e1a-9d3f-2c8b0a7e6d51',
|
||||
mode: 'changes',
|
||||
scan_prefix: '',
|
||||
scope: [],
|
||||
revision: { versioned: true, commit: head, branch: 'feat/x', dirty, base, merge_base: base },
|
||||
revision_source: 'git',
|
||||
model: 'claude-opus-5-5',
|
||||
effort: 'high',
|
||||
run_shape: {
|
||||
requested_effort: 'high', collapsed: null, source: 'coverage.json',
|
||||
diff_files: 2, diff_lines: 30, pre_existing: 0, scope_files: 2,
|
||||
empty_diff: false, empty_scope: false, researchers_dispatched: 3, verification_runs: 1,
|
||||
},
|
||||
findings: { total: records.length, critical: count('CRITICAL'), high: count('HIGH'), medium: count('MEDIUM'), low: count('LOW') },
|
||||
verification: {
|
||||
status: 'verified', candidates: records.length, candidates_deduped: records.length,
|
||||
panel_votes: 3 * records.length, panel_reviewed_findings: records.length,
|
||||
panel_quorum_findings: records.length, unreviewed_candidate_sites: 0,
|
||||
incomplete_panel_candidates: 0, attested_findings: 0, reason: null, reason_kind: null,
|
||||
researchers_dispatched: 3, researchers_returned: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one report folder under `dir` and return its path.
|
||||
* @param {string} dir - the scanned repo's root
|
||||
* @param {{ head: string, base: string, ts?: string, dirty?: boolean, records?: object[],
|
||||
* jsonl?: string|null, stamp?: (s: object) => object, stampName?: string }} opts
|
||||
* `jsonl: null` writes no JSONL file; `stamp` returns the stamp to write.
|
||||
*/
|
||||
export function writeReport(dir, { head, base, ts = '20261108-120000', dirty = false, records = [record()], jsonl, stamp = (s) => s, stampName }) {
|
||||
const folder = join(dir, `CLAUDE-SECURITY-${ts}`);
|
||||
mkdirSync(folder, { recursive: true });
|
||||
writeFileSync(join(folder, '.gitignore'), '*\n');
|
||||
writeFileSync(join(folder, 'CLAUDE-SECURITY-RESULTS.md'), `# Claude Security results\n\n${records.length} finding(s).\n`);
|
||||
writeFileSync(join(folder, 'CLAUDE-SECURITY-RESULTS.sarif'), JSON.stringify({ version: '2.1.0', runs: [] }));
|
||||
if (jsonl !== null) {
|
||||
writeFileSync(join(folder, 'CLAUDE-SECURITY-RESULTS.jsonl'),
|
||||
jsonl ?? records.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
||||
}
|
||||
const name = stampName ?? `CLAUDE-SECURITY-REVISION-${head.slice(0, 12)}${dirty ? '-dirty' : ''}.json`;
|
||||
writeFileSync(join(folder, name), JSON.stringify(stamp(stampOf({ head, base, dirty, records })), null, 2));
|
||||
return folder;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue