Implement CrowdSec integration with API endpoints for managing IP bans and decisions

- Added unit tests for CrowdSec handler, including listing, banning, and unbanning IPs.
- Implemented mock command executor for testing command execution.
- Created tests for various scenarios including successful operations, error handling, and invalid inputs.
- Developed CrowdSec configuration tests to ensure proper handler setup and JSON output.
- Documented security features and identified gaps in CrowdSec, WAF, and Rate Limiting implementations.
- Established acceptance criteria for feature completeness and outlined implementation phases for future work.
This commit is contained in:
GitHub Actions
2025-12-05 17:23:26 +00:00
parent 11357a1a15
commit 72ff6313de
11 changed files with 1589 additions and 27 deletions

View File

@@ -1,19 +1,24 @@
import { useState } from 'react'
import { Button } from '../components/ui/Button'
import { Card } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
import { getSecurityStatus } from '../api/security'
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile } from '../api/crowdsec'
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, CrowdSecDecision } from '../api/crowdsec'
import { createBackup } from '../api/backups'
import { updateSetting } from '../api/settings'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../utils/toast'
import { ConfigReloadOverlay } from '../components/LoadingStates'
import { Shield, ShieldOff, Trash2 } from 'lucide-react'
export default function CrowdSecConfig() {
const { data: status, isLoading, error } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
const [file, setFile] = useState<File | null>(null)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string | null>(null)
const [showBanModal, setShowBanModal] = useState(false)
const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' })
const [confirmUnban, setConfirmUnban] = useState<CrowdSecDecision | null>(null)
const queryClient = useQueryClient()
const backupMutation = useMutation({ mutationFn: () => createBackup() })
@@ -35,6 +40,38 @@ export default function CrowdSecConfig() {
const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } })
const updateModeMutation = useMutation({ mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['security-status'] }) })
// Banned IPs queries and mutations
const decisionsQuery = useQuery({
queryKey: ['crowdsec-decisions'],
queryFn: listCrowdsecDecisions,
enabled: status?.crowdsec?.mode !== 'disabled',
})
const banMutation = useMutation({
mutationFn: () => banIP(banForm.ip, banForm.duration, banForm.reason),
onSuccess: () => {
toast.success(`IP ${banForm.ip} has been banned`)
queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] })
setShowBanModal(false)
setBanForm({ ip: '', duration: '24h', reason: '' })
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to ban IP')
},
})
const unbanMutation = useMutation({
mutationFn: (ip: string) => unbanIP(ip),
onSuccess: (_, ip) => {
toast.success(`IP ${ip} has been unbanned`)
queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] })
setConfirmUnban(null)
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to unban IP')
},
})
const handleExport = async () => {
try {
const blob = await exportCrowdsecConfig()
@@ -88,7 +125,9 @@ export default function CrowdSecConfig() {
importMutation.isPending ||
writeMutation.isPending ||
updateModeMutation.isPending ||
backupMutation.isPending
backupMutation.isPending ||
banMutation.isPending ||
unbanMutation.isPending
// Determine contextual message
const getMessage = () => {
@@ -101,6 +140,12 @@ export default function CrowdSecConfig() {
if (updateModeMutation.isPending) {
return { message: 'Three heads turn...', submessage: 'CrowdSec mode updating' }
}
if (banMutation.isPending) {
return { message: 'Guardian raises shield...', submessage: 'Banning IP address' }
}
if (unbanMutation.isPending) {
return { message: 'Guardian lowers shield...', submessage: 'Unbanning IP address' }
}
return { message: 'Strengthening the guard...', submessage: 'Configuration in progress' }
}
@@ -175,7 +220,159 @@ export default function CrowdSecConfig() {
</div>
</div>
</Card>
{/* Banned IPs Section */}
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-red-400" />
<h3 className="text-md font-semibold">Banned IPs</h3>
</div>
<Button
onClick={() => setShowBanModal(true)}
disabled={status.crowdsec.mode === 'disabled'}
size="sm"
>
<ShieldOff className="h-4 w-4 mr-1" />
Ban IP
</Button>
</div>
{status.crowdsec.mode === 'disabled' ? (
<p className="text-sm text-gray-500">Enable CrowdSec to manage banned IPs</p>
) : decisionsQuery.isLoading ? (
<p className="text-sm text-gray-400">Loading banned IPs...</p>
) : decisionsQuery.error ? (
<p className="text-sm text-red-400">Failed to load banned IPs</p>
) : !decisionsQuery.data?.decisions?.length ? (
<p className="text-sm text-gray-500">No banned IPs</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-2 px-3 text-gray-400 font-medium">IP</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium">Reason</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium">Duration</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium">Banned At</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium">Source</th>
<th className="text-right py-2 px-3 text-gray-400 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{decisionsQuery.data.decisions.map((decision) => (
<tr key={decision.id} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-2 px-3 font-mono text-white">{decision.ip}</td>
<td className="py-2 px-3 text-gray-300">{decision.reason || '-'}</td>
<td className="py-2 px-3 text-gray-300">{decision.duration}</td>
<td className="py-2 px-3 text-gray-300">
{decision.created_at ? new Date(decision.created_at).toLocaleString() : '-'}
</td>
<td className="py-2 px-3 text-gray-300">{decision.source || 'manual'}</td>
<td className="py-2 px-3 text-right">
<Button
variant="danger"
size="sm"
onClick={() => setConfirmUnban(decision)}
>
<Trash2 className="h-3 w-3 mr-1" />
Unban
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
</div>
{/* Ban IP Modal */}
{showBanModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={() => setShowBanModal(false)} />
<div className="relative bg-dark-card rounded-lg p-6 w-[480px] max-w-full">
<h3 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<ShieldOff className="h-5 w-5 text-red-400" />
Ban IP Address
</h3>
<div className="space-y-4">
<Input
label="IP Address"
placeholder="192.168.1.100"
value={banForm.ip}
onChange={(e) => setBanForm({ ...banForm, ip: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">Duration</label>
<select
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white"
value={banForm.duration}
onChange={(e) => setBanForm({ ...banForm, duration: e.target.value })}
>
<option value="1h">1 hour</option>
<option value="4h">4 hours</option>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
<option value="permanent">Permanent</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">Reason</label>
<textarea
placeholder="Reason for banning this IP..."
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
value={banForm.reason}
onChange={(e) => setBanForm({ ...banForm, reason: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3 justify-end mt-6">
<Button variant="secondary" onClick={() => setShowBanModal(false)}>
Cancel
</Button>
<Button
variant="danger"
onClick={() => banMutation.mutate()}
isLoading={banMutation.isPending}
disabled={!banForm.ip.trim()}
>
Ban IP
</Button>
</div>
</div>
</div>
)}
{/* Unban Confirmation Modal */}
{confirmUnban && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={() => setConfirmUnban(null)} />
<div className="relative bg-dark-card rounded-lg p-6 w-[400px] max-w-full">
<h3 className="text-xl font-semibold text-white mb-4">Confirm Unban</h3>
<p className="text-gray-300 mb-6">
Are you sure you want to unban <span className="font-mono text-white">{confirmUnban.ip}</span>?
</p>
<div className="flex gap-3 justify-end">
<Button variant="secondary" onClick={() => setConfirmUnban(null)}>
Cancel
</Button>
<Button
variant="danger"
onClick={() => unbanMutation.mutate(confirmUnban.ip)}
isLoading={unbanMutation.isPending}
>
Unban
</Button>
</div>
</div>
</div>
)}
</>
)
}