feat(review): the brief's threat model on the review side (decision 164, 167, 218)
The code-correctness reviewer's dimension 6 shrinks to what the engines do not read: command, skill and agent markdown carrying shell, with Claude Code's threshold (a concrete attack path, high confidence), the keys SECURITY_INJECTION or SECURITY_RISK, and threat_model_ref quoting the brief's Outside: line; the code-level bullets go to the engines. The coordinator puts a Voyage security finding with a threat_model_ref into the trailing JSON block's known_outside, never into findings. review-finalize gains the pass `outside`, right after engine: it only raises. An entry the brief at the review's brief_path does not back now (line gone, threat model not usable, an engine finding, not a security finding) goes back into the findings at its catalogue tier with a Source line, R-step and raised verdict, as the engine put-back does; the rest are listed under the new `## Outside the Threat Model` section. verdict-gate decideOpenFindings (and so scripts/release-check.mjs) re-checks every entry against brief_path through the new notBackedOutside and exits 2 for one not backed. review-validator shape-checks the optional known_outside (REVIEW_KNOWN_OUTSIDE_MALFORMED). threat-model.mjs gains reviewThreatModel and isKnownEntry for the three. /trekreview Phase 8.8 lists the known issues outside the threat model (not answered, not counted); the review template shows the section and field; Handover 1 names the optional ## Threat Model and its code, Handover 6 names known_outside and its code. Part 2, step 2.5 of the E plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
98171d7d3b
commit
c47061191c
10 changed files with 585 additions and 30 deletions
|
|
@ -2,7 +2,7 @@
|
|||
// One deterministic pass over review.md after the coordinator and after
|
||||
// lib/review/fill-finding-ids.mjs, on both paths (review_version 1.2, fix
|
||||
// round 4.5b2 part B). Its passes run in the Architecture order (engine,
|
||||
// instruction, carry, pre-existing, policy), each at its place in PASSES:
|
||||
// outside, instruction, carry, pre-existing, policy), each at its place in PASSES:
|
||||
//
|
||||
// - engine, always the first pass: `putBack` (lib/review/engine-compare.mjs)
|
||||
// compares the engine payloads Phase 4.7 saved in the project folder
|
||||
|
|
@ -17,6 +17,18 @@
|
|||
// the frontmatter list, counts, a verdict that is only raised, and, as a
|
||||
// BLOCKER, its `### Step R<n>`. An engine finding in `out_of_scope` is
|
||||
// not compared (it stays declined, `engine:out-of-scope`).
|
||||
// - outside, right after engine (E 2.5, decisions 164, 218): the trailing
|
||||
// JSON block's optional `known_outside` (Voyage's own security findings
|
||||
// the coordinator listed as outside the brief's threat model) is checked
|
||||
// against the brief at the review's `brief_path`
|
||||
// (lib/review/threat-model.mjs `reviewThreatModel`, `backedLine`). It only
|
||||
// raises: an entry the brief does not back now (its `Outside:` line gone,
|
||||
// the threat model not usable, an engine finding, not a security finding)
|
||||
// goes back into the findings at its catalogue tier with no id, a `### `
|
||||
// entry with a `**Source:**` line saying why, the frontmatter list, counts,
|
||||
// a verdict that is only raised and, as a BLOCKER, its `### Step R<n>`.
|
||||
// The entries that stay are listed under `## Outside the Threat Model`;
|
||||
// a malformed entry stays too (the verdict gate exits 2 on it).
|
||||
// - instruction, right after engine: a finding under `CLAUDE_MD_DEVIATION`
|
||||
// or `CLAUDE_MD_STALE` whose `instruction_ref` is missing or names a file
|
||||
// outside the repo's tracked instruction set (lib/review/instruction-scope.mjs
|
||||
|
|
@ -111,6 +123,8 @@ import { carry, frontmatterField, MAX_REVIEW_ROUNDS, runId } from './review-roun
|
|||
import { applyPolicy, readPolicy } from './review-policy.mjs';
|
||||
import { ENGINES } from './engine-findings.mjs';
|
||||
import { ENGINE_OF_REVIEWER, putBack } from './engine-compare.mjs';
|
||||
import { backedLine, isKnownEntry, reviewThreatModel } from './threat-model.mjs';
|
||||
import { getRule } from './rule-catalogue.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
|
||||
|
|
@ -243,6 +257,60 @@ function passEngine(payload, ctx) {
|
|||
return { headEdit: (head) => withVerdict(head, r.raisedVerdict) };
|
||||
}
|
||||
|
||||
// ---- pass: outside -----------------------------------------------------------------
|
||||
|
||||
const OUTSIDE_RE = /^##\s+Outside the Threat Model\s*$/;
|
||||
|
||||
/** The `## Outside the Threat Model` section: the entries that stay, and how many went back. */
|
||||
function outsideSection(kept, back) {
|
||||
const note = back > 0 ? `Put back into the findings, the brief does not back them now: ${back}.\n\n` : '';
|
||||
if (kept.length === 0) return `## Outside the Threat Model\n\n${note}None.\n\n`;
|
||||
const items = kept.map((e) => (isKnownEntry(e)
|
||||
? `- ${oneLine(e.title) || '(no title)'} (\`${oneLine(e.file)}:${e.line}\`, \`${oneLine(e.rule_key)}\`) — Outside: ${oneLine(e.threat_model_ref)}`
|
||||
: '- (a malformed entry: the verdict gate cannot check it against the brief)'));
|
||||
return '## Outside the Threat Model\n\n'
|
||||
+ `Voyage's own security findings the brief's threat model puts outside, listed as known issues; they do not count: ${kept.length}.\n\n`
|
||||
+ `${note}${items.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass `outside`, right after engine: each `known_outside` entry the brief does
|
||||
* not back now goes back into the findings at its catalogue tier.
|
||||
*/
|
||||
function passOutside(payload, ctx) {
|
||||
if (!Array.isArray(payload.known_outside) || payload.known_outside.length === 0) return {};
|
||||
const use = reviewThreatModel(ctx.text, ctx.repo);
|
||||
const tm = use.usable ? use.threatModel : null;
|
||||
const kept = [];
|
||||
const back = [];
|
||||
for (const e of payload.known_outside) {
|
||||
if (isKnownEntry(e) && !(tm && backedLine(e, tm) !== null)) back.push(e);
|
||||
else kept.push(e);
|
||||
}
|
||||
const why = tm ? 'no `Outside:` line of the brief equals its threat_model_ref, or it is not a Voyage security finding' : use.why;
|
||||
for (const e of back) {
|
||||
const rule = getRule(e.rule_key.trim().toUpperCase());
|
||||
const f = { ...e, id: null, rule_key: rule ? rule.rule_key : e.rule_key, severity: rule ? rule.severity : 'BLOCKER' };
|
||||
payload.findings.push(f);
|
||||
ctx.added.add(f);
|
||||
ctx.outsideBack.set(f, why);
|
||||
}
|
||||
payload.known_outside = kept;
|
||||
ctx.outside = { kept: kept.length, put_back: back.length };
|
||||
const section = outsideSection(kept, back.length);
|
||||
const edits = [(head) => replaceSection(head, OUTSIDE_RE, section,
|
||||
[/^##\s+Pre-existing\s*$/, /^##\s+Resolved since round\b/, /^##\s+Declined to Judge\b/])];
|
||||
if (back.length > 0) {
|
||||
ctx.recount = true;
|
||||
const v = raisedVerdict(payload.verdict, payload.findings);
|
||||
if (v !== payload.verdict) {
|
||||
payload.verdict = v;
|
||||
edits.push((head) => withVerdict(head, v));
|
||||
}
|
||||
}
|
||||
return { headEdit: (head) => edits.reduce((h, fn) => fn(h), head) };
|
||||
}
|
||||
|
||||
// ---- pass: instruction -------------------------------------------------------------
|
||||
|
||||
const INSTRUCTION_KEYS = new Set(['CLAUDE_MD_DEVIATION', 'CLAUDE_MD_STALE']);
|
||||
|
|
@ -333,7 +401,7 @@ function passInstruction(payload, ctx) {
|
|||
|
||||
// ---- pass: carry -------------------------------------------------------------------
|
||||
|
||||
const LATER_SECTIONS = [/^##\s+Suppressed Findings\b/, /^##\s+Pre-existing\s*$/, /^##\s+Resolved since round\b/,
|
||||
const LATER_SECTIONS = [/^##\s+Suppressed Findings\b/, /^##\s+Outside the Threat Model\s*$/, /^##\s+Pre-existing\s*$/, /^##\s+Resolved since round\b/,
|
||||
/^##\s+Declined to Judge\b/, /^##\s+Remediation Steps\b/, /^##\s+Remediation Summary\b/, /^##\s+Answers\s*$/];
|
||||
|
||||
/** The `## Resolved since round <n>` section. */
|
||||
|
|
@ -531,6 +599,7 @@ function passPolicy(payload, ctx) {
|
|||
/** The passes, in the Architecture order; the id recomputation runs after them, always last. */
|
||||
export const PASSES = Object.freeze([
|
||||
{ name: 'engine', run: passEngine },
|
||||
{ name: 'outside', run: passOutside },
|
||||
{ name: 'instruction', run: passInstruction },
|
||||
{ name: 'carry', run: passCarry },
|
||||
{ name: 'pre-existing', run: passPreExisting },
|
||||
|
|
@ -589,13 +658,17 @@ function editFrontmatter(head, ids, fields) {
|
|||
|
||||
/**
|
||||
* A body entry for a finding finalize added, in the coordinator's form; an
|
||||
* engine put-back names its source first.
|
||||
* engine put-back, or a known issue put back from outside the threat model,
|
||||
* names its source first.
|
||||
*/
|
||||
function entryLines(f, putBackSet) {
|
||||
function entryLines(f, putBackSet, outsideBack) {
|
||||
const lines = [`### ${f.id}`, ''];
|
||||
if (putBackSet.has(f)) lines.push(`**Source:** engine ${oneLine(f.engine)}, put back by the standard-path comparison`, '');
|
||||
if (outsideBack.has(f)) {
|
||||
lines.push(`**Source:** listed outside the threat model (Outside: ${oneLine(f.threat_model_ref)}), put back: ${oneLine(outsideBack.get(f))}`, '');
|
||||
}
|
||||
lines.push(`- file: ${oneLine(f.file)}`, `- line: ${f.line}`, `- rule_key: ${oneLine(f.rule_key)}`);
|
||||
for (const k of ['brief_ref', 'title', 'detail', 'recommended_action', 'engine', 'carried_from']) {
|
||||
for (const k of ['brief_ref', 'title', 'detail', 'recommended_action', 'engine', 'carried_from', 'threat_model_ref']) {
|
||||
if (f[k] !== undefined && f[k] !== null && oneLine(f[k]) !== '') lines.push(`- ${k}: ${oneLine(f[k])}`);
|
||||
}
|
||||
return lines;
|
||||
|
|
@ -612,14 +685,14 @@ function findingsBefore(sev) {
|
|||
* The body entries: one added for each finding in `added`, and a `### <id>`
|
||||
* entry that sits under another severity's `## Findings` section moved to its own.
|
||||
*/
|
||||
function placeEntries(head, findings, added, putBackSet) {
|
||||
function placeEntries(head, findings, added, putBackSet, outsideBack = new Map()) {
|
||||
let lines = head.split('\n');
|
||||
const fmEnd = frontmatterEnd(lines);
|
||||
for (const f of findings) {
|
||||
const sev = sevOf(f);
|
||||
if (!SEVERITIES.includes(sev) || typeof f.id !== 'string') continue;
|
||||
if (added.has(f)) {
|
||||
lines = appendToSection(lines, findingsHeading(sev), `## Findings (${sev})`, entryLines(f, putBackSet), findingsBefore(sev));
|
||||
lines = appendToSection(lines, findingsHeading(sev), `## Findings (${sev})`, entryLines(f, putBackSet, outsideBack), findingsBefore(sev));
|
||||
continue;
|
||||
}
|
||||
const heading = new RegExp(`^###\\s+${f.id}\\s*$`);
|
||||
|
|
@ -727,7 +800,7 @@ export function finalizeReview(text, { repo, round, roundBase = null, previous =
|
|||
if (dup !== undefined) return { exit: 1, reason: `two findings carry the id ${JSON.stringify(dup)}` };
|
||||
|
||||
const ev = evidence ?? { ran: { engines: [], reviewers: [] }, marks: [], payloads: [] };
|
||||
const ctx = { repo, text, round, previous, evidence: ev, added: new Set(), putBack: new Set(), carried: false };
|
||||
const ctx = { repo, text, round, previous, evidence: ev, added: new Set(), putBack: new Set(), outsideBack: new Map(), carried: false };
|
||||
let head = parts.head;
|
||||
const out = { passes: [] };
|
||||
for (const pass of PASSES) {
|
||||
|
|
@ -750,7 +823,7 @@ export function finalizeReview(text, { repo, round, roundBase = null, previous =
|
|||
head = rewriteIds(head, map);
|
||||
out.passes.push('ids');
|
||||
|
||||
head = placeEntries(head, payload.findings, ctx.added, ctx.putBack);
|
||||
head = placeEntries(head, payload.findings, ctx.added, ctx.putBack, ctx.outsideBack);
|
||||
if (ctx.carried || ctx.recount) {
|
||||
payload.counts = Object.fromEntries(SEVERITIES.map((s) => [s, payload.findings.filter((f) => sevOf(f) === s).length]));
|
||||
}
|
||||
|
|
@ -778,6 +851,7 @@ export function finalizeReview(text, { repo, round, roundBase = null, previous =
|
|||
exit: 0, text: `${head}${parts.fence}${body}${parts.tail}`, ...out, ids_rewritten: map.size,
|
||||
carried: ctx.added.size, resolved: payload.resolved ?? [], ran: payload.ran, r_steps_added: steps.added,
|
||||
policy: ctx.policy ?? null, put_back: ctx.engine?.put_back ?? 0, instruction: ctx.instruction ?? null,
|
||||
outside: ctx.outside ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,19 @@
|
|||
// Otherwise every security finding counts, and `why` says why.
|
||||
// backedLine(finding, tm) -> the `Outside:` text the finding's
|
||||
// `threat_model_ref` equals exactly, else null.
|
||||
// reviewThreatModel(reviewText, repo) -> usable() for the brief a review
|
||||
// names in its `brief_path` (a relative path is read from the repo's top
|
||||
// level); the review side's `known_outside` (E 2.5) is checked with it.
|
||||
// isKnownEntry(e) -> a review `known_outside` entry's shape: a
|
||||
// finding with a string `rule_key` and `file`, an integer `line` >= 0
|
||||
// and a non-empty `threat_model_ref`.
|
||||
//
|
||||
// Lifting a finding back in is the operator's: change (or remove) that
|
||||
// `Outside:` line and approve the intent again. Both gates re-read the brief on
|
||||
// every run, so a finding whose line is gone counts in full.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { verifyLog, normalizeHex, normalizeSubject, resolveRepo } from '../util/approval-log.mjs';
|
||||
import { checkIntentApprovalContent } from '../validators/intent-approval.mjs';
|
||||
|
|
@ -85,6 +92,31 @@ export function usable(briefPath, repo) {
|
|||
return { usable: true, why: null, threatModel: tm };
|
||||
}
|
||||
|
||||
/**
|
||||
* The threat model a review applies: usable() for the brief at the review's
|
||||
* frontmatter `brief_path`, a relative path read from the repo's top level.
|
||||
* @param {string} reviewText - review.md
|
||||
* @param {string} [repo] - the repo (default: the git top level of the working directory)
|
||||
* @returns {{usable: boolean, why: string|null, threatModel: object}}
|
||||
*/
|
||||
export function reviewThreatModel(reviewText, repo) {
|
||||
const doc = parseDocument(typeof reviewText === 'string' ? reviewText : '');
|
||||
const bp = doc.parsed?.frontmatter?.brief_path;
|
||||
if (typeof bp !== 'string' || bp.trim() === '') return { usable: false, why: 'the review names no brief_path', threatModel: EMPTY };
|
||||
let root;
|
||||
try { root = resolveRepo(repo); } catch (e) {
|
||||
return { usable: false, why: `the repo could not be read: ${e.message}`, threatModel: EMPTY };
|
||||
}
|
||||
return usable(isAbsolute(bp) ? bp : join(root, bp), root);
|
||||
}
|
||||
|
||||
/** A review `known_outside` entry's shape (E 2.5): string rule_key and file, integer line >= 0, non-empty threat_model_ref. */
|
||||
export function isKnownEntry(e) {
|
||||
return e !== null && typeof e === 'object' && !Array.isArray(e) &&
|
||||
typeof e.rule_key === 'string' && e.rule_key !== '' && typeof e.file === 'string' && e.file !== '' &&
|
||||
Number.isInteger(e.line) && e.line >= 0 && typeof e.threat_model_ref === 'string' && e.threat_model_ref !== '';
|
||||
}
|
||||
|
||||
/** A security finding that is Voyage's own: a security key, no engine, not the engines' key (218). */
|
||||
export function isOwnSecurityFinding(f) {
|
||||
if (!f || typeof f !== 'object') return false;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@
|
|||
// An ALLOW/WARN review that holds one finding id twice, in the
|
||||
// frontmatter `findings:` list or in the trailing JSON block, is exit
|
||||
// 2 (reason `duplicate finding id <id> in <where>`).
|
||||
// An ALLOW/WARN review whose optional `known_outside` (E 2.5: Voyage's
|
||||
// own security findings the brief's threat model puts outside) holds
|
||||
// an entry the brief at its `brief_path` does not back now (its
|
||||
// `Outside:` line gone, the threat model not usable, an engine
|
||||
// finding, not a security finding, a malformed entry or list) is
|
||||
// exit 2 too (reason `review-not-measured: known issue outside the
|
||||
// threat model not backed by the brief (<why>): …`;
|
||||
// `notBackedOutside`): the brief is re-read on every run.
|
||||
// Exit 2 is NEVER green.
|
||||
//
|
||||
// The pure `decideVerdict` stays text-only: it never reads the answers. The
|
||||
|
|
@ -85,6 +93,7 @@ import { frontmatterField, runId } from './review-rounds.mjs';
|
|||
// No cycle: neither module, nor any module they import, imports this gate.
|
||||
import { roundEvidence } from './review-finalize.mjs';
|
||||
import { putBack, normTitle } from './engine-compare.mjs';
|
||||
import { backedLine, isKnownEntry, reviewThreatModel } from './threat-model.mjs';
|
||||
|
||||
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
|
||||
|
||||
|
|
@ -220,6 +229,7 @@ function blockerSectionEntries(body) {
|
|||
const NOT_FINALIZED = 'review-not-measured: round 2+ review not finalized';
|
||||
const MOVED_SERIOUS = 'a policy or filter moved a BLOCKER, MAJOR or security finding';
|
||||
const DROPPED_ENGINE = 'review-not-measured: engine finding not in review';
|
||||
const NOT_BACKED = 'review-not-measured: known issue outside the threat model not backed by the brief';
|
||||
const NOTED_SEVERITIES = new Set(['MINOR', 'SUGGESTION']);
|
||||
|
||||
/** One field of a declined entry for the reason line: one line, at most 120 chars. */
|
||||
|
|
@ -307,6 +317,29 @@ export function droppedEngineFindings(text, reviewPath) {
|
|||
return putBack(review, payloads).added.filter((e) => !carried.some((c) => samePlace(c, e)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `known_outside` entries of review.md the brief at its `brief_path` does
|
||||
* not back now (lib/review/threat-model.mjs `reviewThreatModel`, `backedLine`):
|
||||
* a malformed list or entry, an engine finding, not a security finding, an
|
||||
* `Outside:` line gone, or a threat model that is not usable. An unreadable
|
||||
* trailing block gives none (decideVerdict judges it).
|
||||
* @param {string} text - review.md
|
||||
* @param {string|undefined} repo - the repo's top level
|
||||
* @returns {{entries: Array<*>, why: string|null}}
|
||||
*/
|
||||
export function notBackedOutside(text, repo) {
|
||||
const payload = trailingPayload(String(text ?? ''));
|
||||
if (payload === null || typeof payload !== 'object' || !('known_outside' in payload)) return { entries: [], why: null };
|
||||
const list = payload.known_outside;
|
||||
if (!Array.isArray(list)) return { entries: [list], why: 'known_outside is not a list' };
|
||||
if (list.length === 0) return { entries: [], why: null };
|
||||
const use = reviewThreatModel(text, repo);
|
||||
const tm = use.usable ? use.threatModel : null;
|
||||
const entries = list.filter((e) => !isKnownEntry(e) || !(tm && backedLine(e, tm) !== null));
|
||||
const why = tm ? 'no `Outside:` line of the brief equals its threat_model_ref, it is not a Voyage security finding, or it is malformed' : use.why;
|
||||
return { entries, why: entries.length > 0 ? why : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-findings check: every counting MAJOR of an ALLOW/WARN review must be
|
||||
* answered (lib/review/review-answers.mjs `decideAnswers`). K2 records come
|
||||
|
|
@ -322,6 +355,7 @@ export function droppedEngineFindings(text, reviewPath) {
|
|||
* pre-existing: it keeps its line from the previous round's end. A round 2+
|
||||
* review that was not finalized (`unfinalizedRound`) is exit 2 first, then an
|
||||
* engine MAJOR or BLOCKER missing from the review (`droppedEngineFindings`),
|
||||
* then a `known_outside` entry the brief does not back (`notBackedOutside`),
|
||||
* then a BLOCKER, MAJOR or security finding moved by a policy or filter (`movedSerious`).
|
||||
* @param {string} text - review.md
|
||||
* @param {{ repo?: string, reviewPath: string }} opts
|
||||
|
|
@ -339,6 +373,18 @@ export function decideOpenFindings(text, { repo, reviewPath } = {}) {
|
|||
fixed_pending: [],
|
||||
};
|
||||
}
|
||||
const outside = notBackedOutside(text, repo);
|
||||
if (outside.entries.length > 0) {
|
||||
const what = (e) => (e !== null && typeof e === 'object'
|
||||
? `${field(e.title) ?? '(no title)'} at ${field(e.file) ?? '?'}:${field(e.line) ?? '?'}`
|
||||
: JSON.stringify(e));
|
||||
return {
|
||||
exit: EXIT.UNDECIDED,
|
||||
reason: `${NOT_BACKED} (${outside.why}): ${outside.entries.map(what).join(', ')}`,
|
||||
unanswered: [],
|
||||
fixed_pending: [],
|
||||
};
|
||||
}
|
||||
const hidden = movedSerious(text);
|
||||
if (hidden.length > 0) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { parseDocument } from '../util/frontmatter.mjs';
|
|||
import { issue, ok, fail } from '../util/result.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
import { extractFindingsBlock } from '../review/findings-schema.mjs';
|
||||
import { isKnownEntry } from '../review/threat-model.mjs';
|
||||
|
||||
export const REVIEW_REQUIRED_FRONTMATTER = [
|
||||
'type',
|
||||
|
|
@ -76,8 +77,10 @@ export function remediationStepSources(body) {
|
|||
// `fixed` without its R-step), REVIEW_POLICY_MALFORMED (`review_policy`,
|
||||
// `review_policy_sha256`, a finding's `instruction_ref`), REVIEW_ROUND_MALFORMED
|
||||
// (`round`, `round_base`, `finalized`, `resolved`, `ran`, a finding's
|
||||
// `carried_from`) and REVIEW_PRE_EXISTING_MALFORMED (a finding's `pre_existing`,
|
||||
// `pre_existing_counts`).
|
||||
// `carried_from`), REVIEW_PRE_EXISTING_MALFORMED (a finding's `pre_existing`,
|
||||
// `pre_existing_counts`) and REVIEW_KNOWN_OUTSIDE_MALFORMED (the optional
|
||||
// `known_outside` list, E 2.5: Voyage's own security findings the brief's
|
||||
// threat model puts outside; lib/review/threat-model.mjs `isKnownEntry`).
|
||||
|
||||
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||
const SHA256_RE = /^[0-9a-f]{64}$/;
|
||||
|
|
@ -163,6 +166,7 @@ function review12Issues(fm, body) {
|
|||
const policy = (msg) => out.push(issue('REVIEW_POLICY_MALFORMED', msg));
|
||||
const pre = (msg) => out.push(issue('REVIEW_PRE_EXISTING_MALFORMED', msg));
|
||||
const answers = (msg) => out.push(issue('REVIEW_ANSWERS_MALFORMED', msg));
|
||||
const outside = (msg) => out.push(issue('REVIEW_KNOWN_OUTSIDE_MALFORMED', msg));
|
||||
|
||||
// Frontmatter.
|
||||
if ('round' in fm && !(Number.isInteger(fm.round) && fm.round >= 1 && fm.round <= 3)) {
|
||||
|
|
@ -211,6 +215,17 @@ function review12Issues(fm, body) {
|
|||
round(`resolved must be an array of 40-char hex finding ids, got ${JSON.stringify(r)}`);
|
||||
}
|
||||
}
|
||||
if ('known_outside' in payload) {
|
||||
const k = payload.known_outside;
|
||||
if (!Array.isArray(k)) outside(`known_outside must be an array, got ${JSON.stringify(k)}`);
|
||||
else {
|
||||
k.forEach((e, i) => {
|
||||
if (!isKnownEntry(e)) {
|
||||
outside(`known_outside[${i}] needs a string rule_key and file, an integer line >= 0 and a non-empty threat_model_ref, got ${JSON.stringify(e)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if ('ran' in payload) {
|
||||
const r = payload.ran;
|
||||
const okRan = isPlainObject(r)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue