trapline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +585 -0
  4. data/config.ru.example +21 -0
  5. data/exe/trapline +8 -0
  6. data/lib/generators/trapline/install_generator.rb +34 -0
  7. data/lib/generators/trapline/templates/initializer.rb +23 -0
  8. data/lib/tasks/trapline.rake +19 -0
  9. data/lib/trapline/cli.rb +212 -0
  10. data/lib/trapline/configuration.rb +19 -0
  11. data/lib/trapline/cve/client.rb +203 -0
  12. data/lib/trapline/cve/record.rb +160 -0
  13. data/lib/trapline/engine.rb +243 -0
  14. data/lib/trapline/generator.rb +236 -0
  15. data/lib/trapline/languages.rb +65 -0
  16. data/lib/trapline/railtie.rb +26 -0
  17. data/lib/trapline/rule.rb +165 -0
  18. data/lib/trapline/runtime.rb +148 -0
  19. data/lib/trapline/store.rb +113 -0
  20. data/lib/trapline/suite.rb +44 -0
  21. data/lib/trapline/templates/cwe/cwe-22.yml +167 -0
  22. data/lib/trapline/templates/cwe/cwe-327.yml +124 -0
  23. data/lib/trapline/templates/cwe/cwe-502.yml +123 -0
  24. data/lib/trapline/templates/cwe/cwe-601.yml +124 -0
  25. data/lib/trapline/templates/cwe/cwe-611.yml +87 -0
  26. data/lib/trapline/templates/cwe/cwe-78.yml +166 -0
  27. data/lib/trapline/templates/cwe/cwe-79.yml +164 -0
  28. data/lib/trapline/templates/cwe/cwe-798.yml +125 -0
  29. data/lib/trapline/templates/cwe/cwe-89.yml +189 -0
  30. data/lib/trapline/templates/cwe/cwe-917.yml +46 -0
  31. data/lib/trapline/templates/cwe/cwe-918.yml +166 -0
  32. data/lib/trapline/templates/cwe/cwe-94.yml +131 -0
  33. data/lib/trapline/templates.rb +261 -0
  34. data/lib/trapline/validator.rb +359 -0
  35. data/lib/trapline/version.rb +5 -0
  36. data/lib/trapline/web/app.rb +234 -0
  37. data/lib/trapline/web/assets/THIRD_PARTY_LICENSES.txt +214 -0
  38. data/lib/trapline/web/assets/app.css +399 -0
  39. data/lib/trapline/web/assets/app.js +804 -0
  40. data/lib/trapline/web/assets/codemirror.css +344 -0
  41. data/lib/trapline/web/assets/codemirror.js +13263 -0
  42. data/lib/trapline/web/assets/index.html +165 -0
  43. data/lib/trapline/web/assets/jetbrains-mono-latin-400-normal.woff2 +0 -0
  44. data/lib/trapline/web/assets/jetbrains-mono-latin-600-normal.woff2 +0 -0
  45. data/lib/trapline/web/assets/public-sans-latin-400-normal.woff2 +0 -0
  46. data/lib/trapline/web/assets/public-sans-latin-500-normal.woff2 +0 -0
  47. data/lib/trapline/web/assets/public-sans-latin-600-normal.woff2 +0 -0
  48. data/lib/trapline/web/assets/public-sans-latin-700-normal.woff2 +0 -0
  49. data/lib/trapline/web/server.rb +108 -0
  50. data/lib/trapline.rb +70 -0
  51. metadata +136 -0
@@ -0,0 +1,804 @@
1
+ /* Trapline editor — no build step, no framework. */
2
+ (() => {
3
+ "use strict";
4
+
5
+ const $ = (sel, root = document) => root.querySelector(sel);
6
+ const el = (tag, attrs = {}, ...kids) => {
7
+ const n = document.createElement(tag);
8
+ for (const [k, v] of Object.entries(attrs)) {
9
+ if (v == null || v === false) continue;
10
+ if (k === "class") n.className = v;
11
+ else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
12
+ else n.setAttribute(k, v === true ? "" : v);
13
+ }
14
+ for (const kid of kids.flat()) if (kid != null && kid !== false) n.append(kid.nodeType ? kid : String(kid));
15
+ return n;
16
+ };
17
+ const debounce = (fn, ms) => {
18
+ let t;
19
+ const d = (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
20
+ d.cancel = () => clearTimeout(t);
21
+ return d;
22
+ };
23
+ const plural = (n, word) => `${n} ${n === 1 ? word : `${word}s`}`;
24
+
25
+ async function api(path, { method = "GET", body } = {}) {
26
+ const res = await fetch(path, {
27
+ method,
28
+ headers: { "content-type": "application/json", "x-trapline": "1" },
29
+ body: body === undefined ? undefined : JSON.stringify(body),
30
+ credentials: "same-origin",
31
+ });
32
+ let data = {};
33
+ try { data = await res.json(); } catch (_) { /* no body */ }
34
+ if (!res.ok) {
35
+ const err = new Error(data.error || `Error ${res.status}`);
36
+ err.data = data;
37
+ throw err;
38
+ }
39
+ return data;
40
+ }
41
+
42
+ const MODES = {
43
+ ruby: "ruby", python: "python", javascript: "javascript", typescript: "text/typescript",
44
+ java: "text/x-java", go: "go", csharp: "text/x-csharp", kotlin: "text/x-kotlin", scala: "text/x-scala",
45
+ c: "text/x-csrc", cpp: "text/x-c++src", rust: "rust", bash: "shell", yaml: "yaml",
46
+ json: "application/json", dockerfile: "shell", terraform: null, generic: null, regex: null,
47
+ };
48
+
49
+ function makeEditor(host, { mode, gutter = false } = {}) {
50
+ if (window.CodeMirror) {
51
+ const cm = window.CodeMirror(host, {
52
+ value: "", mode, lineNumbers: true, tabSize: 2, indentUnit: 2, lineWrapping: false,
53
+ gutters: gutter ? ["ann-gutter", "CodeMirror-linenumbers"] : ["CodeMirror-linenumbers"],
54
+ extraKeys: { Tab: (c) => (c.somethingSelected() ? c.execCommand("indentMore") : c.execCommand("insertSoftTab")), "Shift-Tab": "indentLess" },
55
+ });
56
+ let marks = [];
57
+ let lineClasses = [];
58
+ let badges = [];
59
+ return {
60
+ get: () => cm.getValue(),
61
+ set: (v) => { cm.setValue(v || ""); cm.clearHistory(); },
62
+ onChange: (fn) => cm.on("change", (_c, ch) => { if (ch.origin !== "setValue") fn(); }),
63
+ setMode: (m) => cm.setOption("mode", m),
64
+ clearMarks() {
65
+ marks.forEach((m) => m.clear());
66
+ lineClasses.forEach(([l, where, cls]) => cm.removeLineClass(l, where, cls));
67
+ marks = []; lineClasses = [];
68
+ if (gutter) cm.clearGutter("ann-gutter");
69
+ },
70
+ markRange(from, to, cls) {
71
+ marks.push(cm.markText({ line: from.line - 1, ch: Math.max(0, from.col - 1) }, { line: to.line - 1, ch: Math.max(0, to.col - 1) }, { className: cls }));
72
+ },
73
+ markLine(line, cls) {
74
+ if (line < 1 || line > cm.lineCount()) return;
75
+ const h = cm.addLineClass(line - 1, "background", cls);
76
+ lineClasses.push([h, "background", cls]);
77
+ },
78
+ gutterMark(line, node) {
79
+ if (gutter && line >= 1 && line <= cm.lineCount()) cm.setGutterMarker(line - 1, "ann-gutter", node);
80
+ },
81
+ focusLine(line) {
82
+ cm.focus();
83
+ cm.setCursor({ line: line - 1, ch: 0 });
84
+ cm.scrollIntoView({ line: line - 1, ch: 0 }, 80);
85
+ const h = cm.addLineClass(line - 1, "background", "hl-flash");
86
+ setTimeout(() => cm.removeLineClass(h, "background", "hl-flash"), 900);
87
+ },
88
+ refresh: () => cm.refresh(),
89
+ hasFocus: () => cm.hasFocus(),
90
+ // Replaces the whole text as one undoable edit, keeping cursor and scroll.
91
+ replaceAll(text) {
92
+ const scroll = cm.getScrollInfo();
93
+ const cursor = cm.getCursor();
94
+ const last = cm.lastLine();
95
+ cm.replaceRange(text, { line: 0, ch: 0 }, { line: last, ch: cm.getLine(last).length }, "+expect");
96
+ cm.setCursor(cursor);
97
+ cm.scrollTo(scroll.left, scroll.top);
98
+ },
99
+ onGutterClick: (fn) => cm.on("gutterClick", (_c, line) => fn(line + 1)),
100
+ // Shows each range as `node` without touching the text, so the file and
101
+ // its line numbers stay exactly as the engine sees them.
102
+ setBadges(list) {
103
+ cm.operation(() => {
104
+ badges.forEach((m) => m.clear());
105
+ badges = list.map(({ line, from, to, node }) =>
106
+ cm.markText({ line: line - 1, ch: from }, { line: line - 1, ch: to }, { replacedWith: node, atomic: true, handleMouseEvents: true }));
107
+ });
108
+ },
109
+ setCursor(line, ch) { cm.focus(); cm.setCursor({ line: line - 1, ch }); },
110
+ onCursor: (fn) => cm.on("cursorActivity", () => fn(cm.getCursor().line + 1)),
111
+ };
112
+ }
113
+ const ta = el("textarea", { class: "fallback", spellcheck: "false" });
114
+ host.append(ta);
115
+ ta.addEventListener("keydown", (e) => {
116
+ if (e.key === "Tab") {
117
+ e.preventDefault();
118
+ const s = ta.selectionStart;
119
+ ta.setRangeText(" ", s, ta.selectionEnd, "end");
120
+ ta.dispatchEvent(new Event("input"));
121
+ }
122
+ });
123
+ return {
124
+ get: () => ta.value,
125
+ set: (v) => { ta.value = v || ""; },
126
+ onChange: (fn) => ta.addEventListener("input", fn),
127
+ setMode() {}, clearMarks() {}, markRange() {}, markLine() {}, gutterMark() {},
128
+ focusLine(line) {
129
+ const idx = ta.value.split("\n").slice(0, line - 1).join("\n").length + (line > 1 ? 1 : 0);
130
+ ta.focus(); ta.setSelectionRange(idx, idx);
131
+ },
132
+ refresh() {}, hasFocus: () => document.activeElement === ta,
133
+ replaceAll(text) { ta.value = text; ta.dispatchEvent(new Event("input")); },
134
+ onGutterClick() {}, setBadges() {}, setCursor() {}, onCursor() {},
135
+ };
136
+ }
137
+
138
+ const S = {
139
+ info: null, rules: [], slug: null, dirty: false, fields: null, readOnly: false,
140
+ testLang: null, testLangManual: false, annId: null, cve: null,
141
+ };
142
+ const MODE_LABELS = { search: "Search mode", taint: "Taint mode (data flow)", join: "Join mode", extract: "Extract mode" };
143
+
144
+ const yamlEd = makeEditor($("#yaml-editor"), { mode: "yaml" });
145
+ const testEd = makeEditor($("#test-editor"), { mode: "ruby", gutter: true });
146
+ const form = $("#fields");
147
+
148
+ let toastTimer;
149
+ function toast(msg, kind = "info") {
150
+ const t = $("#toast");
151
+ t.textContent = msg;
152
+ t.className = `toast ${kind === "error" || kind === "success" ? kind : ""}`;
153
+ t.hidden = false;
154
+ clearTimeout(toastTimer);
155
+ toastTimer = setTimeout(() => { t.hidden = true; }, kind === "error" ? 6000 : 2600);
156
+ }
157
+
158
+ function showDialog(d) {
159
+ d.returnValue = "";
160
+ d.showModal();
161
+ return new Promise((resolve) => d.addEventListener("close", () => resolve(d.returnValue === "ok"), { once: true }));
162
+ }
163
+
164
+ function confirmDialog(title, text, okLabel) {
165
+ $("#confirm-title").textContent = title;
166
+ $("#confirm-text").textContent = text;
167
+ $("#confirm-ok").textContent = okLabel;
168
+ return showDialog($("#dlg-confirm"));
169
+ }
170
+
171
+ function setDirty(v) {
172
+ S.dirty = v;
173
+ const st = $("#doc-state");
174
+ st.textContent = v ? (S.slug ? "Unsaved changes" : "Not saved yet") : (S.slug ? "Saved" : "");
175
+ st.classList.toggle("dirty", v);
176
+ }
177
+
178
+ function docName() {
179
+ if (S.slug) return `${S.slug}.yaml`;
180
+ return S.fields?.id ? `${S.fields.id} (new)` : "Untitled rule";
181
+ }
182
+
183
+ function setDocName() {
184
+ $("#doc-name").textContent = docName();
185
+ $("#btn-delete").hidden = !S.slug || S.readOnly;
186
+ $("#btn-save").disabled = S.readOnly;
187
+ renderList();
188
+ }
189
+
190
+ function fillSelect(select, items, { value, blank } = {}) {
191
+ select.replaceChildren(...(blank ? [el("option", { value: "" }, blank)] : []),
192
+ ...items.map((i) => el("option", { value: i.key }, i.name)));
193
+ if (value) select.value = value;
194
+ }
195
+
196
+ function renderList() {
197
+ const q = $("#filter").value.trim().toLowerCase();
198
+ const list = $("#rule-list");
199
+ const rules = S.rules.filter((r) => !q || [r.slug, r.id, ...(r.cve || []), ...(r.cwe || [])].join(" ").toLowerCase().includes(q));
200
+ if (!S.rules.length) {
201
+ list.replaceChildren(el("li", { class: "empty" }, "No saved rules yet. Create a new one or generate one from a CVE."));
202
+ return;
203
+ }
204
+ if (!rules.length) {
205
+ list.replaceChildren(el("li", { class: "empty" }, "Nothing matches the filter."));
206
+ return;
207
+ }
208
+ list.replaceChildren(...rules.map((r) => el("li", {},
209
+ el("button", { type: "button", "aria-current": r.slug === S.slug ? "true" : "false", onclick: () => openRule(r.slug) },
210
+ el("span", { class: "rl-id" }, r.slug),
211
+ r.error
212
+ ? el("span", { class: "rl-meta rl-err" }, "Invalid YAML")
213
+ : el("span", { class: "rl-meta" },
214
+ el("span", { class: `rl-sev sev-${r.severity}` }, r.severity || ""),
215
+ el("span", {}, (r.languages || []).join(", ")),
216
+ (r.cve || [])[0] ? el("span", {}, r.cve[0]) : null,
217
+ r.has_test ? null : el("span", {}, "no test"))))));
218
+ }
219
+
220
+ async function loadRules() {
221
+ const data = await api("api/rules");
222
+ S.rules = data.rules;
223
+ S.readOnly = data.read_only;
224
+ $("#rules-path").textContent = `Folder: ${data.path}`;
225
+ renderList();
226
+ }
227
+
228
+ async function guardDirty() {
229
+ if (!S.dirty) return true;
230
+ return confirmDialog("Discard changes?", "The current rule has changes that have not been saved yet.", "Discard");
231
+ }
232
+
233
+ async function openRule(slug) {
234
+ if (slug === S.slug) return;
235
+ if (!(await guardDirty())) return;
236
+ try {
237
+ const r = await api(`api/rules/${slug}`);
238
+ loadDocument({ slug, yaml: r.yaml, test_code: r.test_code || "", language: r.test_language });
239
+ } catch (e) { toast(e.message, "error"); }
240
+ }
241
+
242
+ function loadDocument({ slug = null, yaml, test_code, language }) {
243
+ S.slug = slug;
244
+ S.annId = null;
245
+ S.testLangManual = !!language;
246
+ yamlEd.set(yaml);
247
+ testEd.set(test_code || "");
248
+ testEd.clearMarks();
249
+ revealedLine = null;
250
+ renderBadges();
251
+ if (language) setTestLang(language);
252
+ setDirty(!slug);
253
+ validateNow().then(() => { setDocName(); runNow(); });
254
+ }
255
+
256
+ let validateSeq = 0;
257
+ async function validateNow() {
258
+ const seq = ++validateSeq;
259
+ try {
260
+ const res = await api("api/validate", { method: "POST", body: { yaml: yamlEd.get() } });
261
+ if (seq !== validateSeq) return;
262
+ renderIssues(res.issues || []);
263
+ if (res.fields) {
264
+ if (S.annId && res.fields.id && res.fields.id !== S.annId) renameAnnotations(S.annId, res.fields.id);
265
+ const idChanged = res.fields.id !== S.fields?.id;
266
+ if (res.fields.id) S.annId = res.fields.id;
267
+ S.fields = res.fields;
268
+ if (idChanged) renderBadges();
269
+ fillForm(res.fields);
270
+ if (!S.testLangManual && res.fields.languages?.[0]) setTestLang(res.fields.languages[0]);
271
+ $("#mode").textContent = MODE_LABELS[res.fields.mode] || "";
272
+ $("#doc-name").textContent = docName();
273
+ }
274
+ } catch (e) { renderIssues([{ level: "error", path: "", message: e.message }]); }
275
+ }
276
+ const validateSoon = debounce(validateNow, 350);
277
+
278
+ function renderIssues(issues) {
279
+ const box = $("#issues");
280
+ yamlEd.clearMarks();
281
+ issues.forEach((i) => {
282
+ const m = /line (\d+)/.exec(i.message);
283
+ if (i.level === "error" && m && !i.path) yamlEd.markLine(Number(m[1]), "err-line");
284
+ });
285
+ const relevant = issues.filter((i) => i.level !== "info");
286
+ if (relevant.length) box.replaceChildren(...relevant.map(issueRow));
287
+ else box.replaceChildren(el("p", { class: "ok" }, "Valid structure."), ...issues.slice(0, 2).map(issueRow));
288
+ }
289
+
290
+ function issueRow(i) {
291
+ const label = { error: "Error", warning: "Warning", info: "Tip" }[i.level] || i.level;
292
+ return el("div", { class: `issue issue-${i.level}` },
293
+ el("b", {}, label),
294
+ el("span", {}, i.message, i.path ? el("span", { class: "issue-path" }, ` ${i.path}`) : null));
295
+ }
296
+
297
+ function fillForm(f) {
298
+ const set = (name, v) => {
299
+ const input = form.elements[name];
300
+ if (input && document.activeElement !== input) input.value = v ?? "";
301
+ };
302
+ set("id", f.id);
303
+ set("severity", f.severity);
304
+ set("languages", (f.languages || []).join(", "));
305
+ set("message", f.message);
306
+ set("cwe", (f.metadata?.cwe || []).join(", "));
307
+ set("cve", (f.metadata?.cve || []).join(", "));
308
+ set("confidence", f.metadata?.confidence || "");
309
+ set("references", (f.metadata?.references || []).join("\n"));
310
+ const sum = [f.severity, (f.languages || []).join(", "), ...(f.metadata?.cve || []).slice(0, 1), ...(f.metadata?.cwe || []).slice(0, 1).map((c) => c.split(":")[0])].filter(Boolean);
311
+ $("#details-summary").textContent = sum.join(", ");
312
+ }
313
+
314
+ function formFields() {
315
+ const v = (n) => form.elements[n].value;
316
+ return {
317
+ id: v("id"), severity: v("severity"), languages: v("languages"), message: v("message"),
318
+ metadata: { cwe: v("cwe"), cve: v("cve"), confidence: v("confidence"), references: v("references") },
319
+ };
320
+ }
321
+
322
+ const mergeSoon = debounce(async () => {
323
+ try {
324
+ const res = await api("api/merge", { method: "POST", body: { yaml: yamlEd.get(), fields: formFields() } });
325
+ yamlEd.set(res.yaml);
326
+ setDirty(true);
327
+ await validateNow();
328
+ runSoon();
329
+ } catch (e) { toast(`Fix the YAML before using the form: ${e.message}`, "error"); }
330
+ }, 400);
331
+
332
+ function setTestLang(lang) {
333
+ S.testLang = lang;
334
+ $("#test-lang").value = lang;
335
+ testEd.setMode(MODES[lang] ?? null);
336
+ }
337
+
338
+ let runSeq = 0;
339
+ // Only manual runs (Run button, Ctrl+Enter) toast: "Run on edit" would
340
+ // otherwise pop one on every pause in typing.
341
+ async function runNow({ manual = false } = {}) {
342
+ if (!S.info?.engine) {
343
+ const msg = "No engine found. Install opengrep or semgrep and restart Trapline to run rules.";
344
+ showRunStatus(statusCard("fail", "Cannot run", msg));
345
+ $("#results").replaceChildren();
346
+ if (manual) toast(msg, "error");
347
+ return;
348
+ }
349
+ const seq = ++runSeq;
350
+ setRunning(true);
351
+ try {
352
+ const res = await api("api/run", { method: "POST", body: { yaml: yamlEd.get(), code: testEd.get(), language: S.testLang } });
353
+ if (seq === runSeq) {
354
+ const outcome = renderResults(res);
355
+ if (manual) toast(outcome.title, { fail: "error", pass: "success" }[outcome.state] || "info");
356
+ }
357
+ } catch (e) {
358
+ if (seq === runSeq) {
359
+ testEd.clearMarks(); // stale ✓/✕ from a previous run would be misleading
360
+ showRunStatus(statusCard("fail", "Run failed", e.message));
361
+ $("#results").replaceChildren();
362
+ if (manual) toast(`Run failed: ${e.message}`, "error");
363
+ }
364
+ } finally {
365
+ if (seq === runSeq) { setRunning(false); flashResults(); }
366
+ }
367
+ }
368
+
369
+ // Feedback even when the results do not change (a manual Run after "Run on edit").
370
+ function setRunning(on) {
371
+ const btn = $("#btn-run");
372
+ btn.disabled = on;
373
+ btn.textContent = on ? "Running…" : "Run";
374
+ for (const box of [$("#run-status"), $("#results")]) {
375
+ box.classList.toggle("running", on);
376
+ box.setAttribute("aria-busy", on ? "true" : "false");
377
+ }
378
+ }
379
+
380
+ function flashResults() {
381
+ const box = $("#run-status");
382
+ box.classList.remove("fresh");
383
+ void box.offsetWidth; // restart the animation
384
+ box.classList.add("fresh");
385
+ }
386
+
387
+ function showRunStatus(card) {
388
+ $("#run-status").replaceChildren(card);
389
+ }
390
+ const runSoon = debounce(() => { if ($("#auto-run").checked) runNow(); }, 800);
391
+
392
+ const ICON = { pass: "✓", fail: "✕", todo: "·", hit: "!" };
393
+ const resultKind = (r) => (r.kind === "unexpected" ? "fail" : r.status);
394
+
395
+ const ANN_RE = /(#|\/\/|\/\*|<!--|--)\s*(ruleid|ok|todoruleid|todook)\s*:\s*([A-Za-z0-9._\-, ]+)/;
396
+
397
+ const annotationOnly = (line) => {
398
+ const m = ANN_RE.exec(line || "");
399
+ return m && !line.slice(0, m.index).trim() ? m : null;
400
+ };
401
+ const withoutAnnotation = (line) => {
402
+ const m = ANN_RE.exec(line || "");
403
+ return (m ? line.slice(0, m.index) : line || "").trim();
404
+ };
405
+ const commentPrefix = () => S.info?.languages?.find((l) => l.key === S.testLang)?.comment || "#";
406
+ const ruleId = () => S.fields?.id || "my-rule";
407
+
408
+ // Annotation for 0-based line i: a comment-only line right above it, or a
409
+ // trailing comment on the line itself.
410
+ function annotationFor(lines, i) {
411
+ const above = i > 0 && annotationOnly(lines[i - 1]);
412
+ if (above) return { index: i - 1, kind: above[2] };
413
+ const m = ANN_RE.exec(lines[i] || "");
414
+ return m ? { index: i, kind: m[2], inline: true } : null;
415
+ }
416
+
417
+ function addAnnotation(lines, i, kind) {
418
+ const indent = (lines[i] || "").match(/^\s*/)[0];
419
+ lines.splice(i, 0, `${indent}${commentPrefix()} ${kind}: ${ruleId()}`);
420
+ }
421
+
422
+ function setAnnotationKind(lines, index, kind) {
423
+ lines[index] = lines[index].replace(/\b(?:todoruleid|todook|ruleid|ok)(\s*:)/, `${kind}$1`);
424
+ }
425
+
426
+ function removeAnnotation(lines, index) {
427
+ const m = ANN_RE.exec(lines[index] || "");
428
+ if (!m) return;
429
+ if (annotationOnly(lines[index])) lines.splice(index, 1);
430
+ else lines[index] = lines[index].slice(0, m.index).trimEnd();
431
+ }
432
+
433
+ function editTestCode(fn) {
434
+ const lines = testEd.get().split("\n");
435
+ if (fn(lines) === false) return;
436
+ testEd.replaceAll(lines.join("\n"));
437
+ runSoon.cancel();
438
+ runNow();
439
+ }
440
+
441
+ function cycleExpectation(lineNo) {
442
+ editTestCode((lines) => {
443
+ let i = lineNo - 1;
444
+ while (i < lines.length && annotationOnly(lines[i])) i++; // clicked the comment: act on its code line
445
+ if (i >= lines.length || !lines[i].trim()) {
446
+ toast("Click next to a line of code to mark what the rule should do with it.");
447
+ return false;
448
+ }
449
+ const a = annotationFor(lines, i);
450
+ if (!a) addAnnotation(lines, i, "ruleid");
451
+ else if (a.kind === "ruleid" || a.kind === "todoruleid") setAnnotationKind(lines, a.index, "ok");
452
+ else removeAnnotation(lines, a.index);
453
+ });
454
+ }
455
+
456
+ const BADGES = {
457
+ ruleid: ["must match", "pass"], ok: ["must not match", "fail"],
458
+ todoruleid: ["should match (todo)", "todo"], todook: ["should not match (todo)", "todo"],
459
+ };
460
+ let revealedLine = null; // annotation shown as raw text while the cursor is on it
461
+
462
+ function renderBadges() {
463
+ if ($("#raw-ann").checked) return testEd.setBadges([]);
464
+ const badges = [];
465
+ testEd.get().split("\n").forEach((line, i) => {
466
+ const m = ANN_RE.exec(line);
467
+ if (!m || i + 1 === revealedLine) return;
468
+ const end = m.index + m[0].length;
469
+ const to = /^\s*(\*\/|-->)?\s*$/.test(line.slice(end)) ? line.length : end;
470
+ badges.push({ line: i + 1, from: m.index, to, node: badge(m[2], m[3], line.slice(m.index, to).trim(), i + 1, !!annotationOnly(line)) });
471
+ });
472
+ testEd.setBadges(badges);
473
+ }
474
+
475
+ function badge(kind, ids, raw, lineNo, above) {
476
+ const [label, cls] = BADGES[kind];
477
+ const others = ids.split(",").map((id) => id.trim()).filter((id) => id && id !== ruleId());
478
+ return el("span", {
479
+ class: `ann-badge ann-badge-${cls}`, title: `${raw}\nClick to edit the comment`, "aria-label": `${label}${others.length ? ` for ${others.join(", ")}` : ""}`,
480
+ onmousedown: (e) => { e.preventDefault(); revealLine(lineNo); },
481
+ }, `${above ? "↓ " : ""}${label}`, others.length ? el("span", { class: "ann-badge-ids" }, others.join(", ")) : null);
482
+ }
483
+
484
+ function revealLine(lineNo) {
485
+ revealedLine = lineNo;
486
+ renderBadges();
487
+ const line = testEd.get().split("\n")[lineNo - 1] || "";
488
+ testEd.setCursor(lineNo, line.length);
489
+ }
490
+
491
+ function renameAnnotations(from, to) {
492
+ const text = testEd.get();
493
+ const next = text.split("\n").map((line) => {
494
+ const m = ANN_RE.exec(line);
495
+ if (!m) return line;
496
+ const start = m.index + m[0].length - m[3].length;
497
+ const ids = m[3].replace(/[^,\s]+/g, (id) => (id === from ? to : id));
498
+ return line.slice(0, start) + ids + line.slice(m.index + m[0].length);
499
+ }).join("\n");
500
+ if (next !== text) testEd.replaceAll(next);
501
+ }
502
+
503
+ // Line numbers come from the last run: insert bottom-up so they stay valid.
504
+ function acceptFindings(lineNos, kind) {
505
+ editTestCode((lines) => {
506
+ [...new Set(lineNos)].sort((a, b) => b - a).forEach((l) => addAnnotation(lines, l - 1, kind));
507
+ });
508
+ }
509
+
510
+ function actionsFor(r) {
511
+ const act = (label, title, fn, cls = "") => el("button", { type: "button", class: `res-act ${cls}`, title, onclick: fn }, label);
512
+ if (r.kind === "unexpected" || r.kind === "finding") {
513
+ return [act("Expected", "The rule is right: keep this finding as a test (ruleid)", () => acceptFindings([r.line], "ruleid"), "pass"),
514
+ act("False positive", "The rule should not match here (ok)", () => acceptFindings([r.line], "ok"), "fail")];
515
+ }
516
+ if (r.status !== "fail") return [];
517
+ if (r.kind === "ok") {
518
+ return [act("Expect it", "This finding is correct: change ok to ruleid", () => editTestCode((lines) => setAnnotationKind(lines, r.annotation_line - 1, "ruleid")))];
519
+ }
520
+ return [act("Remove", "Remove this expectation", () => editTestCode((lines) => removeAnnotation(lines, r.annotation_line - 1)))];
521
+ }
522
+
523
+ function resultRow(r, code, kind, reason) {
524
+ return el("li", {},
525
+ el("button", { type: "button", class: "res-main", onclick: () => testEd.focusLine(r.line) },
526
+ el("span", { class: `ann ann-${kind}` }, ICON[kind] || "·"),
527
+ el("span", { class: "res-line" }, `line ${r.line}`),
528
+ el("span", { class: "res-code" }, code, reason ? el("span", { class: "res-reason" }, reason) : null)),
529
+ el("span", { class: "res-acts" }, ...actionsFor(r)));
530
+ }
531
+
532
+ function acceptAllBar(lineNos) {
533
+ if (!lineNos.length) return null;
534
+ return el("div", { class: "res-bar" },
535
+ el("span", {}, `${plural(lineNos.length, "finding")} ${lineNos.length === 1 ? "has" : "have"} no expectation yet.`),
536
+ el("button", { type: "button", class: "res-act pass", onclick: () => acceptFindings(lineNos, "ruleid") },
537
+ lineNos.length === 1 ? "Accept as expected" : "Accept all as expected"));
538
+ }
539
+
540
+ function statusCard(state, title, sub) {
541
+ return el("div", { class: `status-card status-${state}`, role: state === "fail" ? "alert" : "status" },
542
+ el("span", { class: "status-icon", "aria-hidden": "true" }, state === "pass" ? "✓" : state === "fail" ? "✕" : "i"),
543
+ el("div", { class: "status-text" },
544
+ el("strong", { class: "status-title" }, title),
545
+ sub ? el("span", { class: "status-sub" }, sub) : null));
546
+ }
547
+
548
+ function renderResults(res) {
549
+ testEd.clearMarks();
550
+ const findings = res.findings || [];
551
+ const engineErrors = res.errors || [];
552
+ const ann = res.annotations;
553
+ const count = findings.length;
554
+ const ranAt = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
555
+ const runInfo = [res.duration != null ? `in ${res.duration} s` : "", res.engine ? `with ${res.engine}` : ""].filter(Boolean).join(" ");
556
+ const sub = `${plural(count, "finding")}${runInfo ? ` ${runInfo}` : ""} · ran at ${ranAt}`;
557
+
558
+ findings.forEach((f) => {
559
+ testEd.markRange({ line: f.line, col: f.col }, { line: f.end_line, col: f.end_col }, "hl-finding");
560
+ for (let l = f.line; l <= f.end_line; l++) testEd.markLine(l, "hl-finding-line");
561
+ });
562
+
563
+ let state;
564
+ let title;
565
+ let detail;
566
+ const nodes = [];
567
+ if (ann) {
568
+ ann.results.forEach((r) => {
569
+ const kind = resultKind(r);
570
+ testEd.gutterMark(r.line, el("span", { class: `ann ann-${kind}`, title: r.reason || `${r.kind}: ok` }, ICON[kind] || "·"));
571
+ });
572
+ const failed = `${ann.failed} of ${ann.total} annotations failed`;
573
+ state = ann.failed === 0 && !engineErrors.length ? "pass" : "fail";
574
+ // With a broken rule the annotation counts are meaningless: errors first.
575
+ title = engineErrors.length ? "The engine reported errors"
576
+ : ann.failed ? failed : `Passed: ${ann.passed} of ${ann.total} annotations match`;
577
+ detail = engineErrors.length ? `${failed} · ${sub}` : sub;
578
+
579
+ const lines = testEd.get().split("\n");
580
+ const failFirst = (r) => (r.status === "fail" ? 0 : 1);
581
+ nodes.push(acceptAllBar(ann.results.filter((r) => r.kind === "unexpected").map((r) => r.line)),
582
+ el("ul", { class: "res-list" }, ...ann.results.slice()
583
+ .sort((x, y) => failFirst(x) - failFirst(y) || x.line - y.line)
584
+ .map((r) => resultRow(r, withoutAnnotation(lines[r.line - 1]) || (lines[r.line - 1] || "").trim(), resultKind(r), r.reason))));
585
+ } else {
586
+ findings.forEach((f) => testEd.gutterMark(f.line, el("span", { class: "ann ann-hit", title: f.message }, ICON.hit)));
587
+ state = engineErrors.length ? "fail" : "info";
588
+ title = engineErrors.length ? "The engine reported errors" : count ? `Ran: ${plural(count, "finding")}` : "Ran: no findings";
589
+ detail = [runInfo, `ran at ${ranAt}`, "No expectations yet: accept the findings below or click the margin next to a line."]
590
+ .filter(Boolean).join(" · ");
591
+ if (count) {
592
+ const findingLines = [...new Set(findings.map((f) => f.line))];
593
+ nodes.push(acceptAllBar(findingLines), el("ul", { class: "res-list" }, ...findingLines.map((l) => {
594
+ const f = findings.find((x) => x.line === l);
595
+ return resultRow({ kind: "finding", line: l }, (f.code || "").split("\n")[0].trim(), "hit", null);
596
+ })));
597
+ }
598
+ }
599
+ if (engineErrors.length) nodes.unshift(el("p", { class: "engine-error" }, engineErrors.join("\n")));
600
+ showRunStatus(statusCard(state, title, detail));
601
+ $("#results").replaceChildren(...nodes.filter(Boolean));
602
+ return { state, title };
603
+ }
604
+
605
+ async function save() {
606
+ if (S.readOnly) return toast("The editor is in read-only mode.", "error");
607
+ let slug = S.slug;
608
+ if (!slug) {
609
+ slug = await askSlug();
610
+ if (!slug) return;
611
+ }
612
+ try {
613
+ const res = await api(`api/rules/${slug}`, { method: "PUT", body: { yaml: yamlEd.get(), test_code: testEd.get(), language: S.testLang } });
614
+ S.slug = slug;
615
+ setDirty(false);
616
+ await loadRules();
617
+ setDocName();
618
+ const errs = (res.issues || []).filter((i) => i.level === "error").length;
619
+ toast(errs ? `Rule saved, but with ${errs} validation ${errs === 1 ? "error" : "errors"}` : "Rule saved");
620
+ } catch (e) { toast(e.message, "error"); }
621
+ }
622
+
623
+ // Registry-style ids (<lang>.<framework>.security.<name>) name the file
624
+ // after their last segment.
625
+ function ruleFileName(id) {
626
+ const name = (id || "rule").split(".").filter(Boolean).pop() || "rule";
627
+ return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "rule";
628
+ }
629
+
630
+ function askSlug() {
631
+ const d = $("#dlg-save");
632
+ const input = $("#form-save").elements.slug;
633
+ const base = ruleFileName(S.fields?.id);
634
+ let slug = base;
635
+ for (let n = 2; S.rules.some((r) => r.slug === slug); n++) slug = `${base}-${n}`;
636
+ input.value = slug;
637
+ $("#save-path").textContent = "The rule and its test code are stored side by side in the rules folder, in the semgrep --test layout.";
638
+ const closed = showDialog(d);
639
+ input.select();
640
+ return closed.then((ok) => (ok ? input.value.trim() : null));
641
+ }
642
+
643
+ async function removeRule() {
644
+ if (!S.slug) return;
645
+ const ok = await confirmDialog("Delete rule?", `${S.slug}.yaml and its test file will be deleted from the rules folder.`, "Delete");
646
+ if (!ok) return;
647
+ try {
648
+ await api(`api/rules/${S.slug}`, { method: "DELETE" });
649
+ toast("Rule deleted");
650
+ S.slug = null;
651
+ await loadRules();
652
+ setDirty(true);
653
+ setDocName();
654
+ } catch (e) { toast(e.message, "error"); }
655
+ }
656
+
657
+ function download() {
658
+ const blob = new Blob([yamlEd.get()], { type: "text/yaml" });
659
+ const a = el("a", { href: URL.createObjectURL(blob), download: `${S.slug?.split("/").pop() || ruleFileName(S.fields?.id)}.yaml` });
660
+ document.body.append(a);
661
+ a.click();
662
+ a.remove();
663
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
664
+ }
665
+
666
+ async function newRule() {
667
+ if (!(await guardDirty())) return;
668
+ if (!(await showDialog($("#dlg-new")))) return;
669
+ const f = $("#form-new").elements;
670
+ try {
671
+ const s = await api("api/templates/new", { method: "POST", body: { kind: f.kind.value, language: f.language.value, id: f.id.value } });
672
+ loadDocument({ yaml: s.yaml, test_code: s.test_code, language: s.language });
673
+ } catch (e) { toast(e.message, "error"); }
674
+ }
675
+
676
+ function openCve() {
677
+ $("#cve-result").replaceChildren();
678
+ $("#btn-cve-open").disabled = true;
679
+ S.cve = null;
680
+ $("#dlg-cve").showModal();
681
+ $("#form-cve").elements.id.focus();
682
+ }
683
+
684
+ async function fetchCve(manual) {
685
+ const f = $("#form-cve").elements;
686
+ const box = $("#cve-result");
687
+ box.replaceChildren(el("p", { class: "loading" }, manual ? "Generating drafts..." : `Looking up ${f.id.value.trim().toUpperCase()} in NVD and OSV...`));
688
+ $("#btn-cve-open").disabled = true;
689
+ try {
690
+ const body = { language: f.language.value };
691
+ let res;
692
+ if (manual) {
693
+ body.record = {
694
+ id: f.id.value.trim() || "CVE-0000-0000", description: f.m_description.value, cwes: f.m_cwe.value,
695
+ packages: f.m_package.value.trim() ? [{ ecosystem: f.m_ecosystem.value, name: f.m_package.value.trim(), fixed: f.m_fixed.value.trim() || null }] : [],
696
+ };
697
+ res = await api("api/cve/manual", { method: "POST", body });
698
+ } else {
699
+ body.id = f.id.value;
700
+ res = await api("api/cve", { method: "POST", body });
701
+ }
702
+ S.cve = res;
703
+ $("#cve-manual").open = false;
704
+ renderCve(res);
705
+ } catch (e) {
706
+ box.replaceChildren(el("p", { class: "cve-error" }, e.message,
707
+ manual ? null : " If the network is blocked, use the option to enter the data manually."));
708
+ if (!manual) $("#cve-manual").open = true;
709
+ }
710
+ }
711
+
712
+ function renderCve(res) {
713
+ const r = res.record;
714
+ const box = $("#cve-result");
715
+ const pkgs = (r.packages || []).map((p) => `${p.name} (${p.ecosystem})${p.fixed ? `, fixed in ${p.fixed}` : ""}`);
716
+ box.replaceChildren(
717
+ el("div", { class: "cve-head" },
718
+ el("div", { class: "cve-title" },
719
+ el("span", { class: "cve-id" }, r.id),
720
+ r.severity ? el("span", { class: `cve-sev sev-${r.severity}` }, `${r.severity}${r.cvss_score ? ` ${r.cvss_score}` : ""}`) : null,
721
+ r.summary ? el("span", {}, r.summary) : null),
722
+ r.description ? el("p", { class: "cve-desc" }, r.description.length > 420 ? `${r.description.slice(0, 420)}…` : r.description) : null,
723
+ el("div", { class: "cve-facts" },
724
+ r.cwes?.length ? el("span", {}, `CWE: ${r.cwes.join(", ")}`) : el("span", {}, "CWE not reported"),
725
+ pkgs.length ? el("span", {}, `Packages: ${pkgs.join("; ")}`) : null,
726
+ r.aliases?.length ? el("span", {}, `Also known as ${r.aliases.join(", ")}`) : null,
727
+ r.sources?.length ? el("span", {}, `Source: ${r.sources.join(", ")}`) : null)),
728
+ res.notes?.length ? el("ul", { class: "cve-notes" }, ...res.notes.map((n) => el("li", {}, n))) : null,
729
+ el("div", { class: "cands", role: "radiogroup", "aria-label": "Drafts" }, ...res.candidates.map((c, i) =>
730
+ el("label", { class: "cand" },
731
+ el("input", { type: "radio", name: "cand", value: String(i), checked: i === 0 }),
732
+ el("span", {},
733
+ el("span", { class: "cand-title" }, `${c.title}, ${c.language}`),
734
+ el("ul", { class: "cand-notes" }, ...(c.notes || []).map((n) => el("li", {}, n))))))));
735
+ $("#btn-cve-open").disabled = !res.candidates.length;
736
+ }
737
+
738
+ async function openCandidate() {
739
+ const i = Number(($("input[name=cand]:checked", $("#cve-result")) || {}).value || 0);
740
+ const c = S.cve?.candidates?.[i];
741
+ if (!c) return;
742
+ if (!(await guardDirty())) return;
743
+ $("#dlg-cve").close();
744
+ $("#details").open = true;
745
+ loadDocument({ yaml: c.yaml, test_code: c.test_code, language: c.language });
746
+ }
747
+
748
+ yamlEd.onChange(() => { setDirty(true); validateSoon(); runSoon(); });
749
+ testEd.onChange(() => { setDirty(true); renderBadges(); runSoon(); });
750
+ testEd.onCursor((line) => { if (revealedLine && line !== revealedLine) { revealedLine = null; renderBadges(); } });
751
+ $("#raw-ann").addEventListener("change", renderBadges);
752
+ form.addEventListener("input", () => mergeSoon());
753
+ form.addEventListener("submit", (e) => e.preventDefault());
754
+ $("#test-lang").addEventListener("change", (e) => { S.testLangManual = true; setTestLang(e.target.value); runSoon(); });
755
+ $("#btn-run").addEventListener("click", () => runNow({ manual: true }));
756
+ testEd.onGutterClick(cycleExpectation);
757
+ $("#btn-save").addEventListener("click", save);
758
+ $("#btn-delete").addEventListener("click", removeRule);
759
+ $("#btn-download").addEventListener("click", download);
760
+ $("#btn-new").addEventListener("click", newRule);
761
+ $("#btn-cve").addEventListener("click", openCve);
762
+ $("#form-cve").addEventListener("submit", (e) => { e.preventDefault(); fetchCve(false); });
763
+ $("#btn-cve-manual").addEventListener("click", () => fetchCve(true));
764
+ $("#btn-cve-close").addEventListener("click", () => $("#dlg-cve").close());
765
+ $("#btn-cve-open").addEventListener("click", openCandidate);
766
+ $("#filter").addEventListener("input", renderList);
767
+ $("#details").addEventListener("toggle", () => { yamlEd.refresh(); });
768
+ document.addEventListener("keydown", (e) => {
769
+ const mod = e.metaKey || e.ctrlKey;
770
+ if (mod && e.key.toLowerCase() === "s") { e.preventDefault(); save(); }
771
+ if (mod && e.key === "Enter") { e.preventDefault(); runNow({ manual: true }); }
772
+ });
773
+ window.addEventListener("beforeunload", (e) => { if (S.dirty) { e.preventDefault(); e.returnValue = ""; } });
774
+
775
+ (async function boot() {
776
+ try {
777
+ S.info = await api("api/info");
778
+ const eng = $("#engine");
779
+ if (S.info.engine) eng.textContent = `${S.info.engine.name} ${S.info.engine.version}`;
780
+ else { eng.textContent = "Engine not found"; eng.classList.add("missing"); }
781
+
782
+ // regex exists only as a test language (container image drafts).
783
+ const langs = S.info.languages.filter((l) => l.key !== "regex");
784
+ fillSelect($("#test-lang"), S.info.languages, { value: "ruby" });
785
+ fillSelect($("#new-lang"), langs.filter((l) => ["ruby", "python", "javascript", "typescript", "java", "go", "php", "csharp", "kotlin", "rust", "bash", "yaml", "dockerfile", "terraform", "generic"].includes(l.key)), { value: "ruby" });
786
+ fillSelect($("#cve-lang"), langs.filter((l) => ["ruby", "python", "javascript", "java", "go"].includes(l.key)), { blank: "Detect" });
787
+ fillSelect(form.elements.severity, S.info.severities.map((s) => ({ key: s, name: s })));
788
+
789
+ const tpl = await api("api/templates");
790
+ $("#kinds").replaceChildren(el("legend", { class: "sr-only" }, "Rule type"), ...tpl.starters.map((s, i) =>
791
+ el("label", { class: "kind" }, el("input", { type: "radio", name: "kind", value: s.kind, checked: i === 0 }), s.title)));
792
+
793
+ await loadRules();
794
+ if (S.rules.length && !S.rules[0].error) {
795
+ await openRule(S.rules[0].slug);
796
+ } else {
797
+ const s = await api("api/templates/new", { method: "POST", body: { kind: "patterns", language: "ruby", id: "my-rule" } });
798
+ loadDocument({ yaml: s.yaml, test_code: s.test_code, language: s.language });
799
+ }
800
+ } catch (e) {
801
+ toast(`Could not start: ${e.message}`, "error");
802
+ }
803
+ })();
804
+ })();