mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 14:07:36 +02:00
Retesting a finding creates an independent conversation branch that
snapshots the source history and model trace, so verification can run
multi-turn without writing to the original session.
- POST /api/vulnerabilities/:id/retest creates the branch and persists the
prompt, so a lost response, navigation or refresh can resume it
- new update_vulnerability_status tool closes the loop; a retest branch may
only read and update its own finding
- {{vulnerability_id}}, {{conversation_id}} and {{source_conversation_id}}
resolve on send, in the first turn and in follow-ups
- evidence appends to a read-only vulnerabilities.retest_log column instead
of retest_notes: that field is operator-authored and the edit form
rewrites it wholesale, so a routine edit used to erase the retest trail
- the retest prompt is prefilled with the operator's retest_notes, which
already states how to verify the fix
- copied messages are re-bound as time.Time. Scanning a DATETIME column into
a string yields RFC3339Nano while AddMessage writes the driver's
space-separated format, and messages sort on the raw created_at text, so a
branch rendered its new turn above the inherited history
155 lines
7.4 KiB
JavaScript
155 lines
7.4 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const vm = require('node:vm');
|
|
const test = require('node:test');
|
|
|
|
function setup() {
|
|
const source = fs.readFileSync('web/static/js/vulnerability.js', 'utf8');
|
|
const elements = Object.fromEntries(['vulnerability-retest-prompt', 'vulnerability-retest-error', 'vulnerability-retest-confirm', 'vulnerability-retest-title'].map(id => [id, { value: '', textContent: '', disabled: false }]));
|
|
elements['vulnerability-retest-prompt'].value = 'Check {{vulnerability_id}} here {{conversation_id}}';
|
|
elements['chat-input'] = { value: '', dispatchEvent() {} };
|
|
const sent = [], requests = [], alerts = [];
|
|
let navigateResolve;
|
|
const navigation = new Promise(resolve => { navigateResolve = resolve; });
|
|
const ctx = vm.createContext({
|
|
document: { getElementById: id => elements[id], dispatchEvent() {} },
|
|
window: { currentConversationId: 'source' },
|
|
Event: class {}, CustomEvent: class {},
|
|
vulnT: key => key, closeAppModal() {}, openAppModal() {}, alert: message => alerts.push(message),
|
|
apiFetch: async (url, options) => {
|
|
requests.push({url, ...options});
|
|
return { ok: true, json: async () => ({ conversation: {id: 'branch', projectId: 'project'}, prompt: 'resolved verification for finding in branch' }) };
|
|
},
|
|
navigateToConversation: () => navigation,
|
|
sendMessage: async options => { sent.push({ id: ctx.window.currentConversationId, prompt: elements['chat-input'].value }); options.onRequestAccepted(); },
|
|
});
|
|
vm.runInContext(source.slice(source.indexOf("let vulnerabilityRetestId = '';")), ctx);
|
|
vm.runInContext("vulnerabilityRetestId = 'finding';", ctx);
|
|
return {ctx, elements, sent, requests, alerts, navigateResolve};
|
|
}
|
|
|
|
test('confirmation creates one branch and waits for navigation before sending', async () => {
|
|
const s = setup();
|
|
const first = s.ctx.confirmVulnerabilityRetest();
|
|
await s.ctx.confirmVulnerabilityRetest();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(s.requests.length, 1);
|
|
assert.equal(s.requests[0].url, '/api/vulnerabilities/finding/retest');
|
|
assert.equal(JSON.parse(s.requests[0].body).prompt, s.elements['vulnerability-retest-prompt'].value);
|
|
assert.equal(s.sent.length, 0);
|
|
s.ctx.window.currentConversationId = 'branch';
|
|
s.navigateResolve();
|
|
await first;
|
|
assert.deepEqual(s.sent, [{id: 'branch', prompt: 'resolved verification for finding in branch'}]);
|
|
assert.equal(s.elements['vulnerability-retest-confirm'].disabled, false);
|
|
});
|
|
|
|
test('switching to another conversation prevents retest prompt being sent there', async () => {
|
|
const s = setup();
|
|
const work = s.ctx.confirmVulnerabilityRetest();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
s.ctx.window.currentConversationId = 'unrelated';
|
|
s.navigateResolve();
|
|
await work;
|
|
assert.equal(s.sent.length, 0);
|
|
assert.equal(s.alerts.length, 1);
|
|
assert.equal(s.elements['chat-input'].value, '');
|
|
});
|
|
|
|
test('an empty prompt does not create a branch', async () => {
|
|
const s = setup();
|
|
s.elements['vulnerability-retest-prompt'].value = ' ';
|
|
await s.ctx.confirmVulnerabilityRetest();
|
|
assert.equal(s.requests.length, 0);
|
|
assert.equal(s.elements['vulnerability-retest-error'].textContent, 'vulnerabilityRetest.promptRequired');
|
|
});
|
|
|
|
test('an active retest stream does not lock out the next retest confirmation', async () => {
|
|
const s = setup();
|
|
let finish;
|
|
const running = new Promise(resolve => { finish = resolve; });
|
|
s.ctx.sendMessage = options => { options.onRequestAccepted(); return running; };
|
|
s.ctx.window.currentConversationId = 'branch';
|
|
s.navigateResolve();
|
|
await s.ctx.confirmVulnerabilityRetest();
|
|
await s.ctx.confirmVulnerabilityRetest();
|
|
assert.equal(s.requests.length, 2);
|
|
finish();
|
|
});
|
|
|
|
|
|
test('slow responses from an older finding cannot overwrite the selected finding', async () => {
|
|
const s = setup();
|
|
const pending = {};
|
|
s.ctx.apiFetch = url => new Promise(resolve => { pending[url] = resolve; });
|
|
const first = s.ctx.openVulnerabilityRetest('first');
|
|
const second = s.ctx.openVulnerabilityRetest('second');
|
|
pending['/api/vulnerabilities/second']({ok: true, json: async () => ({title: 'Second finding'})});
|
|
await second;
|
|
pending['/api/vulnerabilities/first']({ok: true, json: async () => ({title: 'First finding'})});
|
|
await first;
|
|
assert.equal(s.elements['vulnerability-retest-title'].textContent, 'Second finding');
|
|
assert.equal(vm.runInContext('vulnerabilityRetestId', s.ctx), 'second');
|
|
});
|
|
|
|
test('the modal stays locked through send preflight, but unlocks on HTTP acceptance', async () => {
|
|
const s = setup();
|
|
let accept, finish;
|
|
const stream = new Promise(resolve => { finish = resolve; });
|
|
s.ctx.sendMessage = options => { accept = options.onRequestAccepted; return stream; };
|
|
s.ctx.window.currentConversationId = 'branch';
|
|
s.navigateResolve();
|
|
const work = s.ctx.confirmVulnerabilityRetest();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await s.ctx.confirmVulnerabilityRetest();
|
|
assert.equal(s.requests.length, 1);
|
|
assert.equal(s.elements['vulnerability-retest-confirm'].disabled, true);
|
|
accept();
|
|
await work;
|
|
assert.equal(s.elements['vulnerability-retest-confirm'].disabled, false);
|
|
finish();
|
|
});
|
|
|
|
test('a send cancelled before acceptance is reported with its recoverable branch ID', async () => {
|
|
const s = setup();
|
|
s.ctx.sendMessage = async () => {};
|
|
s.ctx.window.currentConversationId = 'branch';
|
|
s.navigateResolve();
|
|
await s.ctx.confirmVulnerabilityRetest();
|
|
assert.equal(s.alerts.length, 1);
|
|
assert.match(s.alerts[0], /branch/);
|
|
assert.match(s.alerts[0], /startFailed/);
|
|
});
|
|
|
|
test('navigation while creating a branch does not take over the new page', async () => {
|
|
const s = setup();
|
|
let respond;
|
|
s.ctx.window.location = {hash: '#vulnerabilities'};
|
|
s.ctx.apiFetch = () => new Promise(resolve => { respond = resolve; });
|
|
let navigations = 0;
|
|
s.ctx.navigateToConversation = () => { navigations++; };
|
|
const work = s.ctx.confirmVulnerabilityRetest();
|
|
s.ctx.window.location.hash = '#chat?conversation=other';
|
|
respond({ok: true, json: async () => ({conversation:{id:'branch'},prompt:'saved prompt'})});
|
|
await work;
|
|
assert.equal(navigations, 0);
|
|
assert.equal(s.sent.length, 0);
|
|
assert.match(s.alerts[0], /branch/);
|
|
});
|
|
|
|
test('the retest prompt carries the operator-written verification method', async () => {
|
|
const s = setup();
|
|
s.ctx.apiFetch = () => Promise.resolve({ok: true, json: async () => ({title: 'SQLi', retest_notes: '重放登录请求,应返回 403'})});
|
|
await s.ctx.openVulnerabilityRetest('finding-1');
|
|
assert.equal(s.elements['vulnerability-retest-title'].textContent, 'SQLi');
|
|
assert.equal(s.elements['vulnerability-retest-prompt'].value,
|
|
'vulnerabilityRetest.defaultPrompt\n\nvulnerabilityRetest.guidanceHeading\n重放登录请求,应返回 403');
|
|
});
|
|
|
|
test('a finding without a verification method gets the plain default prompt', async () => {
|
|
const s = setup();
|
|
s.ctx.apiFetch = () => Promise.resolve({ok: true, json: async () => ({title: 'SQLi', retest_notes: ' '})});
|
|
await s.ctx.openVulnerabilityRetest('finding-2');
|
|
assert.equal(s.elements['vulnerability-retest-prompt'].value, 'vulnerabilityRetest.defaultPrompt');
|
|
});
|