nyx/frontend/src/utils/findingMarkdown.ts

205 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
FindingView,
Evidence,
FlowStep,
SpanEvidence,
CodeContextView,
RelatedFindingView,
} from '../api/types';
import { parseNoteText } from './parseNote';
function firstLine(s: string): string {
const nl = s.indexOf('\n');
return nl === -1 ? s : s.slice(0, nl);
}
function fence(lang: string | undefined, body: string): string {
const hint = (lang || '').toLowerCase();
return `\`\`\`${hint}\n${body}\n\`\`\``;
}
function formatSpan(s: SpanEvidence, lang: string | undefined): string {
const header = `\`${s.path}:${s.line}:${s.col}\` (kind: ${s.kind})`;
if (!s.snippet) return header;
return `${header}\n${fence(lang, s.snippet)}`;
}
function formatEvidence(ev: Evidence, lang: string | undefined): string {
const parts: string[] = [];
if (ev.explanation) {
parts.push(`### Explanation\n${ev.explanation}`);
}
const hasSpans =
ev.source ||
ev.sink ||
(ev.guards && ev.guards.length > 0) ||
(ev.sanitizers && ev.sanitizers.length > 0) ||
ev.state;
if (hasSpans) {
const lines: string[] = ['### Evidence'];
if (ev.source) {
lines.push(`**Source**: ${formatSpan(ev.source, lang)}`);
}
if (ev.sink) {
lines.push(`**Sink**: ${formatSpan(ev.sink, lang)}`);
}
if (ev.source || ev.sink) {
if (!ev.guards || ev.guards.length === 0) {
lines.push(`**Guards**: none`);
} else {
lines.push(`**Guards**:`);
for (const g of ev.guards) {
lines.push(`- ${formatSpan(g, lang)}`);
}
}
if (!ev.sanitizers || ev.sanitizers.length === 0) {
lines.push(`**Sanitizers**: none`);
} else {
lines.push(`**Sanitizers**:`);
for (const s of ev.sanitizers) {
lines.push(`- ${formatSpan(s, lang)}`);
}
}
}
if (ev.state) {
const st = ev.state;
const subj = st.subject ? ` ${st.subject}:` : '';
lines.push(
`**State**: ${st.machine} -${subj} ${st.from_state} -> ${st.to_state}`,
);
}
parts.push(lines.join('\n'));
}
if (ev.confidence_limiters && ev.confidence_limiters.length > 0) {
const lines: string[] = ['**Confidence limiters**:'];
for (const l of ev.confidence_limiters) lines.push(`- ${l}`);
parts.push(lines.join('\n'));
}
return parts.join('\n\n');
}
function formatFlow(steps: FlowStep[]): string {
const lines: string[] = [`### Flow (${steps.length} steps)`];
for (const s of steps) {
const segs: string[] = [`${s.step}. **${s.kind}** \`${s.file}:${s.line}\``];
if (s.snippet) segs.push(`- \`${s.snippet}\``);
if (s.variable) segs.push(`(var \`${s.variable}\`)`);
if (s.callee) segs.push(`(callee \`${s.callee}\`)`);
if (s.is_cross_file) segs.push(`[cross-file]`);
lines.push(segs.join(' '));
}
return lines.join('\n');
}
function formatCodeContext(
cc: CodeContextView,
lang: string | undefined,
): string {
const width = String(cc.start_line + cc.lines.length - 1).length;
const body = cc.lines
.map((line, i) => {
const ln = cc.start_line + i;
const marker = ln === cc.highlight_line ? '>' : ' ';
return `${String(ln).padStart(width, ' ')}${marker} ${line}`;
})
.join('\n');
return `### Code context (lines ${cc.start_line}${
cc.start_line + cc.lines.length - 1
}, highlight line ${cc.highlight_line})\n${fence(lang, body)}`;
}
function formatRelated(related: RelatedFindingView[]): string {
const lines: string[] = ['### Related findings'];
for (const r of related) {
lines.push(
`- \`#${r.index}\` \`${r.rule_id}\` - \`${r.path}:${r.line}\` (${r.severity})`,
);
}
return lines.join('\n');
}
export function findingToMarkdown(f: FindingView): string {
const lang = f.language;
const heading = firstLine(f.message || '').trim() || f.category;
const parts: string[] = [];
parts.push(`## ${f.rule_id} - ${heading}`);
const meta: string[] = [];
meta.push(`- **Rule**: \`${f.rule_id}\` (category: \`${f.category}\`)`);
meta.push(
`- **Severity**: ${f.severity} | **Confidence**: ${f.confidence ?? 'unknown'}`,
);
meta.push(`- **Location**: \`${f.path}:${f.line}:${f.col}\``);
meta.push(`- **Language**: ${f.language ?? 'unknown'}`);
meta.push(
`- **Status**: ${f.status} | **Triage**: ${f.triage_state || 'open'}`,
);
meta.push(`- **Fingerprint**: \`${f.fingerprint}\``);
if (f.sanitizer_status) {
meta.push(`- **Sanitizer status**: ${f.sanitizer_status}`);
}
parts.push(meta.join('\n'));
if (f.message) {
parts.push(`### Message\n${f.message}`);
}
if (f.evidence) {
const ev = formatEvidence(f.evidence, lang);
if (ev) parts.push(ev);
if (f.evidence.flow_steps && f.evidence.flow_steps.length > 0) {
parts.push(formatFlow(f.evidence.flow_steps));
}
}
if (f.code_context) {
parts.push(formatCodeContext(f.code_context, lang));
}
if (f.labels && f.labels.length > 0) {
const lines: string[] = ['### Labels'];
for (const [k, v] of f.labels) lines.push(`- \`${k}\`: \`${v}\``);
parts.push(lines.join('\n'));
}
if (f.evidence?.notes && f.evidence.notes.length > 0) {
const lines: string[] = ['### Notes'];
for (const n of f.evidence.notes) lines.push(`- ${parseNoteText(n)}`);
parts.push(lines.join('\n'));
}
if (f.triage_note) {
parts.push(`### Triage note\n${f.triage_note}`);
}
if (
f.confidence &&
(f.rank_score != null || (f.rank_reason && f.rank_reason.length > 0))
) {
const lines: string[] = ['### Confidence reasoning'];
if (f.rank_score != null) lines.push(`Score: ${f.rank_score.toFixed(1)}`);
if (f.rank_reason && f.rank_reason.length > 0) {
for (const [k, v] of f.rank_reason) lines.push(`- **${k}**: ${v}`);
}
parts.push(lines.join('\n'));
}
if (f.related_findings && f.related_findings.length > 0) {
parts.push(formatRelated(f.related_findings));
}
return parts.join('\n\n');
}
export function findingsToMarkdown(fs: FindingView[]): string {
const header = `# Nyx findings (${fs.length})`;
if (fs.length === 0) return `${header}\n\n(none)`;
return [header, ...fs.map(findingToMarkdown)].join('\n\n---\n\n');
}