mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-18 20:15:14 +02:00
205 lines
6.1 KiB
TypeScript
205 lines
6.1 KiB
TypeScript
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');
|
||
}
|