Files
baffle-hub/app/views/rules/new.html.erb
Dan Milne 90823a1389 Yeh
2025-11-15 10:51:58 +11:00

470 lines
20 KiB
Plaintext

<% content_for :title, "Create New Rule" %>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Create New Rule</h1>
<p class="mt-2 text-gray-600">Create a WAF rule to allow, block, or rate limit traffic</p>
</div>
<div class="bg-white shadow rounded-lg">
<%= form_with(model: @rule, local: true, class: "space-y-6") do |form| %>
<% if @rule.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
There were <%= pluralize(@rule.errors.count, "error") %> with your submission:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @rule.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- Rule Type Selection -->
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Rule Configuration</h3>
</div>
<div class="px-6 py-4 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :waf_rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :waf_rule_type,
options_for_select(@waf_rule_types.map { |type, _| [type.humanize, type] }, @rule.waf_rule_type),
{ prompt: "Select rule type" },
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
id: "rule_type_select" } %>
<p class="mt-2 text-sm text-gray-500">Choose the type of rule you want to create</p>
</div>
<div>
<%= form.label :waf_action, "Action", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :waf_action,
options_for_select(@waf_actions.map { |action, _| [action.humanize, action] }, @rule.waf_action),
{ prompt: "Select action" },
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
<p class="mt-2 text-sm text-gray-500">What action to take when this rule matches</p>
</div>
</div>
<!-- Network Range Selection (shown for network rules) -->
<div id="network_range_section" class="hidden">
<%= form.label :network_range_id, "Network Range", class: "block text-sm font-medium text-gray-700 mb-2" %>
<!-- Selected Network Range Display -->
<div id="selected_network_display" class="hidden mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
<div class="flex justify-between items-center">
<div>
<h4 class="text-sm font-medium text-blue-800">Selected Network Range</h4>
<div id="selected_network_info" class="mt-1 text-sm text-blue-700"></div>
</div>
<button type="button" onclick="clearSelectedNetwork()" class="text-blue-600 hover:text-blue-800">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Network Selection Interface -->
<div id="network_selection_interface" class="space-y-4">
<!-- Search Input -->
<div>
<input type="text"
id="network_search"
placeholder="Search by CIDR, IP, company, or ASN..."
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<p class="mt-2 text-sm text-gray-500">Search existing network ranges or enter a CIDR/IP address below</p>
</div>
<!-- Quick Create Input -->
<div class="flex space-x-2">
<%= text_field_tag :new_cidr, params[:cidr],
placeholder: "e.g., 192.168.1.0/24 or 203.0.113.1",
class: "flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
id: "new_cidr_input" %>
<button type="button" onclick="quickCreateNetwork()"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500">
Create & Select
</button>
</div>
<!-- Search Results -->
<div id="network_search_results" class="hidden">
<div class="border rounded-md divide-y max-h-64 overflow-y-auto">
<!-- Results will be populated here -->
</div>
</div>
<!-- Hidden field to store selected network range ID -->
<%= form.hidden_field :network_range_id, id: "selected_network_range_id", value: @rule.network_range_id %>
</div>
</div>
<!-- Path Pattern (shown for path_pattern rules) -->
<div id="path_pattern_section" class="hidden space-y-6">
<div>
<%= form.label :path_pattern, "Path Pattern", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag :path_pattern, "",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "/admin, /wp-login.php, /.env, /phpmyadmin",
id: "path_pattern_input" %>
<p class="mt-2 text-sm text-gray-500">Enter the path to match (e.g., /admin, /wp-login.php)</p>
</div>
<div>
<%= form.label :match_type, "Match Type", class: "block text-sm font-medium text-gray-700" %>
<%= select_tag :match_type,
options_for_select([
["Exact - Matches path exactly", "exact"],
["Prefix - Matches path and subpaths (e.g., /admin matches /admin/users)", "prefix"],
["Suffix - Matches paths ending with pattern (e.g., /.env matches /backup/.env)", "suffix"],
["Contains - Matches paths containing pattern anywhere", "contains"]
]),
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
id: "match_type_select" } %>
<p class="mt-2 text-sm text-gray-500">How the pattern should be matched against request paths</p>
</div>
<!-- Example Matches (dynamically updated) -->
<div id="match_examples" class="bg-gray-50 border border-gray-200 rounded-md p-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Example Matches:</h4>
<ul class="text-sm text-gray-600 space-y-1" id="example_list">
<li>Enter a pattern to see examples</li>
</ul>
</div>
</div>
<!-- Conditions (shown for other non-network rules) -->
<div id="conditions_section" class="hidden">
<div>
<%= form.label :conditions, "Conditions", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :conditions, rows: 4,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: '{"user_agent": "bot*"}' %>
<p class="mt-2 text-sm text-gray-500">JSON format with matching conditions</p>
</div>
</div>
<!-- Metadata -->
<div data-controller="json-validator" data-json-validator-valid-class="json-valid" data-json-validator-invalid-class="json-invalid" data-json-validator-valid-status-class="json-valid-status" data-json-validator-invalid-status-class="json-invalid-status">
<%= form.label :metadata, "Metadata", class: "block text-sm font-medium text-gray-700" %>
<div class="relative">
<%= form.text_area :metadata, rows: 3,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: '{"reason": "Suspicious activity detected", "source": "manual"}',
data: { json_validator_target: "textarea", action: "input->json-validator#validate" } %>
<div class="mt-1 flex items-center justify-between">
<div data-json-validator-target="status" class="text-sm"></div>
<div class="flex space-x-2">
<button type="button"
data-action="click->json-validator#format"
class="text-xs text-gray-500 hover:text-gray-700 underline">
Format JSON
</button>
<button type="button"
data-action="click->json-validator#insertSample"
data-json-validator-json-sample='{"reason": "Block malicious ISP", "threat_type": "botnet", "confidence": "high", "source": "manual"}'
class="text-xs text-gray-500 hover:text-gray-700 underline">
Insert Sample
</button>
</div>
</div>
</div>
<p class="mt-2 text-sm text-gray-500">JSON format with additional metadata</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :source,
options_for_select(Rule::SOURCES.map { |source| [source.humanize, source] }, @rule.source || "manual"),
{ },
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
<p class="mt-2 text-sm text-gray-500">How this rule was created</p>
</div>
<div>
<%= form.label :expires_at, "Expires At", class: "block text-sm font-medium text-gray-700" %>
<%= form.datetime_local_field :expires_at,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-2 text-sm text-gray-500">Leave blank for permanent rule</p>
</div>
<div class="flex items-center pt-6">
<%= form.check_box :enabled, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :enabled, "Enable immediately", class: "ml-2 block text-sm text-gray-900" %>
</div>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<div class="flex justify-end space-x-3">
<%= link_to "Cancel", rules_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
<%= form.submit "Create Rule", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
</div>
</div>
<% end %>
</div>
</div>
<script>
let selectedNetworkData = null;
document.addEventListener('DOMContentLoaded', function() {
const ruleTypeSelect = document.getElementById('rule_type_select');
const networkSection = document.getElementById('network_range_section');
const pathPatternSection = document.getElementById('path_pattern_section');
const conditionsSection = document.getElementById('conditions_section');
const pathPatternInput = document.getElementById('path_pattern_input');
const matchTypeSelect = document.getElementById('match_type_select');
function toggleSections() {
const ruleType = ruleTypeSelect.value;
// Hide all sections first
networkSection.classList.add('hidden');
pathPatternSection.classList.add('hidden');
conditionsSection.classList.add('hidden');
// Show appropriate section
if (ruleType === 'network') {
networkSection.classList.remove('hidden');
} else if (ruleType === 'path_pattern') {
pathPatternSection.classList.remove('hidden');
} else {
conditionsSection.classList.remove('hidden');
}
}
function updatePathExamples() {
const pattern = pathPatternInput.value.trim();
const matchType = matchTypeSelect.value;
const exampleList = document.getElementById('example_list');
if (!pattern) {
exampleList.innerHTML = '<li>Enter a pattern to see examples</li>';
return;
}
let examples = [];
const cleanPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
switch(matchType) {
case 'exact':
examples = [
`✓ ${cleanPattern}`,
`✗ ${cleanPattern}/users (extra segments)`,
`✗ /api${cleanPattern} (not at root)`
];
break;
case 'prefix':
examples = [
`✓ ${cleanPattern}`,
`✓ ${cleanPattern}/users`,
`✓ ${cleanPattern}/dashboard/settings`,
`✗ /api${cleanPattern} (not at start)`
];
break;
case 'suffix':
examples = [
`✓ ${cleanPattern}`,
`✓ /backup${cleanPattern}`,
`✓ /config/backup${cleanPattern}`,
`✗ ${cleanPattern}/test (extra at end)`
];
break;
case 'contains':
examples = [
`✓ ${cleanPattern}`,
`✓ /api${cleanPattern}/users`,
`✓ /super/secret${cleanPattern}/panel`,
`✗ ${cleanPattern}tool (different segment)`
];
break;
}
exampleList.innerHTML = examples.map(ex => `<li>${ex}</li>`).join('');
}
ruleTypeSelect.addEventListener('change', toggleSections);
pathPatternInput.addEventListener('input', updatePathExamples);
matchTypeSelect.addEventListener('change', updatePathExamples);
toggleSections(); // Initial state
// Pre-select network range if provided
<% if @rule.network_range.present? %>
// Show selected network display
const displayDiv = document.getElementById('selected_network_display');
const infoDiv = document.getElementById('selected_network_info');
const selectionInterface = document.getElementById('network_selection_interface');
let infoHtml = '<strong><%= @rule.network_range.network %></strong>';
<% if @rule.network_range.company.present? %>
infoHtml += ' - <%= @rule.network_range.company %>';
<% end %>
<% if @rule.network_range.asn_org.present? %>
infoHtml += ' (ASN: <%= @rule.network_range.asn_org %>)';
<% end %>
infoDiv.innerHTML = infoHtml;
displayDiv.classList.remove('hidden');
selectionInterface.classList.add('hidden');
<% end %>
// Pre-fill CIDR if provided
<% if params[:cidr].present? %>
if (ruleTypeSelect.value === 'network') {
document.getElementById('new_cidr_input').value = '<%= params[:cidr] %>';
}
<% end %>
// Set up search on Enter key
document.getElementById('network_search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
searchNetworkRanges();
}
});
// Set up quick create on Enter key
document.getElementById('new_cidr_input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
quickCreateNetwork();
}
});
});
function searchNetworkRanges() {
const query = document.getElementById('network_search').value.trim();
if (!query) return;
const resultsDiv = document.getElementById('network_search_results');
resultsDiv.innerHTML = '<div class="p-4 text-center text-gray-500">Searching...</div>';
resultsDiv.classList.remove('hidden');
fetch(`/network_ranges/search?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
if (data.length === 0) {
resultsDiv.innerHTML = '<div class="p-4 text-center text-gray-500">No network ranges found. Try creating a new one below.</div>';
return;
}
const html = data.map(network => `
<div class="p-3 hover:bg-gray-50 cursor-pointer flex justify-between items-center"
onclick="selectNetworkRange(${network.id}, '${network.network}', '${network.company || ''}', '${network.asn_org || ''}')">
<div>
<div class="font-medium text-gray-900">${network.network}</div>
${network.company ? `<div class="text-sm text-gray-600">${network.company}</div>` : ''}
${network.asn_org ? `<div class="text-sm text-gray-500">ASN: ${network.asn} - ${network.asn_org}</div>` : ''}
${network.country ? `<div class="text-sm text-gray-400">Country: ${network.country}</div>` : ''}
</div>
<div class="text-xs text-gray-400">
${network.is_datacenter ? '<span class="bg-gray-100 px-2 py-1 rounded">DC</span>' : ''}
${network.is_vpn ? '<span class="bg-blue-100 px-2 py-1 rounded">VPN</span>' : ''}
${network.is_proxy ? '<span class="bg-red-100 px-2 py-1 rounded">Proxy</span>' : ''}
</div>
</div>
`).join('');
resultsDiv.innerHTML = html;
})
.catch(error => {
console.error('Search error:', error);
resultsDiv.innerHTML = '<div class="p-4 text-center text-red-500">Search failed. Please try again.</div>';
});
}
function selectNetworkRange(id, network, company, asnOrg) {
selectedNetworkData = { id, network, company, asnOrg };
// Update hidden field
document.getElementById('selected_network_range_id').value = id;
// Update display
const displayDiv = document.getElementById('selected_network_display');
const infoDiv = document.getElementById('selected_network_info');
let infoHtml = `<strong>${network}</strong>`;
if (company) infoHtml += ` - ${company}`;
if (asnOrg) infoHtml += ` (ASN: ${asnOrg})`;
infoDiv.innerHTML = infoHtml;
displayDiv.classList.remove('hidden');
// Hide the entire selection interface
document.getElementById('network_selection_interface').classList.add('hidden');
// Clear search results
document.getElementById('network_search_results').classList.add('hidden');
document.getElementById('network_search').value = '';
}
function clearSelectedNetwork() {
selectedNetworkData = null;
document.getElementById('selected_network_range_id').value = '';
document.getElementById('selected_network_display').classList.add('hidden');
// Show the selection interface again
document.getElementById('network_selection_interface').classList.remove('hidden');
}
function quickCreateNetwork() {
const cidr = document.getElementById('new_cidr_input').value.trim();
if (!cidr) {
alert('Please enter a CIDR or IP address');
return;
}
// Simple CIDR validation
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}(\/\d{1,3})?$/;
if (!cidrRegex.test(cidr)) {
alert('Invalid CIDR or IP address format');
return;
}
// Create network range via API
fetch('/network_ranges', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
network_range: {
network: cidr,
source: 'manual',
creation_reason: 'Created from rule form'
}
})
})
.then(response => response.json())
.then(data => {
if (data.id) {
selectNetworkRange(data.id, data.network, data.company, data.asn_org);
document.getElementById('new_cidr_input').value = '';
} else {
alert('Failed to create network range: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Create error:', error);
alert('Failed to create network range. Please try again.');
});
}
</script>