From 2e6af3b2623da3d002816a6de325133d626858c9 Mon Sep 17 00:00:00 2001 From: VaiTon Date: Mon, 5 Jun 2023 16:54:12 +0200 Subject: Frontend to jsx --- frontend/src/components/panels/ConnectionsPane.jsx | 310 ++++++++++++++ frontend/src/components/panels/MainPane.jsx | 112 +++++ frontend/src/components/panels/PcapsPane.jsx | 287 +++++++++++++ frontend/src/components/panels/RulesPane.jsx | 469 +++++++++++++++++++++ frontend/src/components/panels/SearchPane.jsx | 309 ++++++++++++++ frontend/src/components/panels/ServicesPane.jsx | 233 ++++++++++ frontend/src/components/panels/StatsPane.jsx | 274 ++++++++++++ frontend/src/components/panels/StreamsPane.jsx | 453 ++++++++++++++++++++ 8 files changed, 2447 insertions(+) create mode 100644 frontend/src/components/panels/ConnectionsPane.jsx create mode 100644 frontend/src/components/panels/MainPane.jsx create mode 100644 frontend/src/components/panels/PcapsPane.jsx create mode 100644 frontend/src/components/panels/RulesPane.jsx create mode 100644 frontend/src/components/panels/SearchPane.jsx create mode 100644 frontend/src/components/panels/ServicesPane.jsx create mode 100644 frontend/src/components/panels/StatsPane.jsx create mode 100644 frontend/src/components/panels/StreamsPane.jsx (limited to 'frontend/src/components/panels') diff --git a/frontend/src/components/panels/ConnectionsPane.jsx b/frontend/src/components/panels/ConnectionsPane.jsx new file mode 100644 index 0000000..2c7fadd --- /dev/null +++ b/frontend/src/components/panels/ConnectionsPane.jsx @@ -0,0 +1,310 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React, {Component} from "react"; +import Table from "react-bootstrap/Table"; +import {Redirect} from "react-router"; +import {withRouter} from "react-router-dom"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import log from "../../log"; +import {updateParams} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import Connection from "../objects/Connection"; +import ConnectionMatchedRules from "../objects/ConnectionMatchedRules"; +import "./ConnectionsPane.scss"; + +import classNames from 'classnames'; + +class ConnectionsPane extends Component { + + state = { + loading: false, + connections: [], + firstConnection: null, + lastConnection: null, + }; + + constructor(props) { + super(props); + + this.scrollTopThreashold = 0.00001; + this.scrollBottomThreashold = 0.99999; + this.maxConnections = 200; + this.queryLimit = 50; + this.connectionsListRef = React.createRef(); + this.lastScrollPosition = 0; + } + + componentDidMount() { + let urlParams = new URLSearchParams(this.props.location.search); + this.setState({urlParams}); + + const additionalParams = {limit: this.queryLimit}; + + const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/); + if (match != null) { + const id = match[1]; + additionalParams.from = id; + backend.get(`/api/connections/${id}`) + .then((res) => this.connectionSelected(res.json)) + .catch((error) => log.error("Error loading initial connection", error)); + } + + this.loadConnections(additionalParams, urlParams, true).then(() => log.debug("Connections loaded")); + + dispatcher.register("connections_filters", this.handleConnectionsFilters); + dispatcher.register("timeline_updates", this.handleTimelineUpdates); + dispatcher.register("notifications", this.handleNotifications); + dispatcher.register("pulse_connections_view", this.handlePulseConnectionsView); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleConnectionsFilters); + dispatcher.unregister(this.handleTimelineUpdates); + dispatcher.unregister(this.handleNotifications); + dispatcher.unregister(this.handlePulseConnectionsView); + } + + handleConnectionsFilters = (payload) => { + const newParams = updateParams(this.state.urlParams, payload); + if (this.state.urlParams.toString() === newParams.toString()) { + return; + } + + log.debug("Update following url params:", payload); + this.queryStringRedirect = true; + this.setState({urlParams: newParams}); + + this.loadConnections({limit: this.queryLimit}, newParams) + .then(() => log.info("ConnectionsPane reloaded after query string update")); + }; + + handleTimelineUpdates = (payload) => { + this.connectionsListRef.current.scrollTop = 0; + this.loadConnections({ + "started_after": Math.round(payload.from.getTime() / 1000), + "started_before": Math.round(payload.to.getTime() / 1000), + limit: this.maxConnections + }).then(() => log.info(`Loading connections between ${payload.from} and ${payload.to}`)); + }; + + handleNotifications = (payload) => { + if (payload.event === "rules.new" || payload.event === "rules.edit") { + this.loadRules().then(() => log.debug("Loaded connection rules after notification update")); + } + if (payload.event === "services.edit") { + this.loadServices().then(() => log.debug("Services reloaded after notification update")); + } + }; + + handlePulseConnectionsView = (payload) => { + this.setState({pulseConnectionsView: true}); + setTimeout(() => this.setState({pulseConnectionsView: false}), payload.duration); + }; + + connectionSelected = (c) => { + this.connectionSelectedRedirect = true; + this.setState({selected: c.id}); + this.props.onSelected(c); + log.debug(`Connection ${c.id} selected`); + }; + + handleScroll = (e) => { + if (this.disableScrollHandler) { + this.lastScrollPosition = e.currentTarget.scrollTop; + return; + } + + let relativeScroll = e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight); + if (!this.state.loading && relativeScroll > this.scrollBottomThreashold) { + this.loadConnections({from: this.state.lastConnection.id, limit: this.queryLimit,}) + .then(() => log.info("Following connections loaded")); + } + if (!this.state.loading && relativeScroll < this.scrollTopThreashold) { + this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,}) + .then(() => log.info("Previous connections loaded")); + if (this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: false}); + } + } else { + if (this.lastScrollPosition > e.currentTarget.scrollTop) { + if (!this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: true}); + } + } else { + if (this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: false}); + } + } + } + this.lastScrollPosition = e.currentTarget.scrollTop; + }; + + async loadConnections(additionalParams, initialParams = null, isInitial = false) { + if (!initialParams) { + initialParams = this.state.urlParams; + } + const urlParams = new URLSearchParams(initialParams.toString()); + for (const [name, value] of Object.entries(additionalParams)) { + urlParams.set(name, value); + } + + this.setState({loading: true}); + if (!this.state.rules) { + await this.loadRules(); + } + if (!this.state.services) { + await this.loadServices(); + } + + let res = (await backend.get(`/api/connections?${urlParams}`)).json; + + let connections = this.state.connections; + let firstConnection = this.state.firstConnection; + let lastConnection = this.state.lastConnection; + + if (additionalParams && additionalParams.from && !additionalParams.to) { + if (res.length > 0) { + if (!isInitial) { + res = res.slice(1); + } + connections = this.state.connections.concat(res); + lastConnection = connections[connections.length - 1]; + if (isInitial) { + firstConnection = connections[0]; + } + if (connections.length > this.maxConnections) { + connections = connections.slice(connections.length - this.maxConnections, + connections.length - 1); + firstConnection = connections[0]; + } + } + } else if (additionalParams && additionalParams.to && !additionalParams.from) { + if (res.length > 0) { + connections = res.slice(0, res.length - 1).concat(this.state.connections); + firstConnection = connections[0]; + if (connections.length > this.maxConnections) { + connections = connections.slice(0, this.maxConnections); + lastConnection = connections[this.maxConnections - 1]; + } + } + } else { + if (res.length > 0) { + connections = res; + firstConnection = connections[0]; + lastConnection = connections[connections.length - 1]; + } else { + connections = []; + firstConnection = null; + lastConnection = null; + } + } + + this.setState({loading: false, connections, firstConnection, lastConnection}); + + if (firstConnection != null && lastConnection != null) { + dispatcher.dispatch("connection_updates", { + from: new Date(lastConnection["started_at"]), + to: new Date(firstConnection["started_at"]) + }); + } + } + + loadRules = async () => { + return backend.get("/api/rules").then((res) => this.setState({rules: res.json})); + }; + + loadServices = async () => { + return backend.get("/api/services").then((res) => this.setState({services: res.json})); + }; + + render() { + let redirect; + if (this.connectionSelectedRedirect) { + redirect = ; + this.connectionSelectedRedirect = false; + } else if (this.queryStringRedirect) { + redirect = ; + this.queryStringRedirect = false; + } + + let loading = null; + if (this.state.loading) { + loading = + Loading... + ; + } + + return ( +
+ {this.state.showMoreRecentButton &&
+ { + this.disableScrollHandler = true; + this.connectionsListRef.current.scrollTop = 0; + this.loadConnections({limit: this.queryLimit}) + .then(() => { + this.disableScrollHandler = false; + log.info("Most recent connections loaded"); + }); + }}/> +
} + +
+ + + + + + + + + + + + + + + + + { + this.state.connections.flatMap((c) => { + return [ this.connectionSelected(c)} + selected={this.state.selected === c.id} + onMarked={(marked) => c.marked = marked} + onCommented={(comment) => c.comment = comment} + services={this.state.services}/>, + c.matched_rules.length > 0 && + + ]; + }) + } + {loading} + +
servicesrcipsrcportdstipdstportstarted_atdurationupdownactions
+ + {redirect} +
+
+ ); + } + +} + +export default withRouter(ConnectionsPane); diff --git a/frontend/src/components/panels/MainPane.jsx b/frontend/src/components/panels/MainPane.jsx new file mode 100644 index 0000000..ce72be5 --- /dev/null +++ b/frontend/src/components/panels/MainPane.jsx @@ -0,0 +1,112 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React, {Component} from "react"; +import Typed from "typed.js"; +import dispatcher from "../../dispatcher"; +import "./common.scss"; +import "./MainPane.scss"; +import PcapsPane from "./PcapsPane"; +import RulesPane from "./RulesPane"; +import ServicesPane from "./ServicesPane"; +import StreamsPane from "./StreamsPane"; + +class MainPane extends Component { + + state = {}; + + componentDidMount() { + const nl = "^600\n^400"; + const options = { + strings: [ + `welcome to caronte!^1000 the current version is ${this.props.version}` + nl + + "caronte is a network analyzer,^300 it is able to read pcaps and extract connections", // 0 + "the left panel lists all connections that have already been closed" + nl + + "scrolling up the list will load the most recent connections,^300 downward the oldest ones", // 1 + "by selecting a connection you can view its content,^300 which will be shown in the right panel" + nl + + "you can choose the display format,^300 or decide to download the connection content", // 2 + "below there is the timeline,^300 which shows the number of connections per minute per service" + nl + + "you can use the sliding window to move the time range of the connections to be displayed", // 3 + "there are also additional metrics,^300 selectable from the drop-down menu", // 4 + "at the top are the filters,^300 which can be used to select only certain types of connections" + nl + + "you can choose which filters to display in the top bar from the filters window", // 5 + "in the pcaps panel it is possible to analyze new pcaps,^300 or to see the pcaps already analyzed" + nl + + "you can load pcaps from your browser,^300 or process pcaps already present on the filesystem", // 6 + "in the rules panel you can see the rules already created,^300 or create new ones" + nl + + "the rules inserted will be used only to label new connections, not those already analyzed" + nl + + "a connection is tagged if it meets all the requirements specified by the rule", // 7 + "in the services panel you can assign new services or edit existing ones" + nl + + "each service is associated with a port number,^300 and will be shown in the connection list", // 8 + "from the configuration panel you can change the settings of the frontend application", // 9 + "that's all! and have fun!" + nl + "created by @eciavatta" // 10 + ], + typeSpeed: 40, + cursorChar: "_", + backSpeed: 5, + smartBackspace: false, + backDelay: 1500, + preStringTyped: (arrayPos) => { + switch (arrayPos) { + case 1: + return dispatcher.dispatch("pulse_connections_view", {duration: 12000}); + case 2: + return this.setState({backgroundPane: }); + case 3: + this.setState({backgroundPane: null}); + return dispatcher.dispatch("pulse_timeline", {duration: 12000}); + case 6: + return this.setState({backgroundPane: }); + case 7: + return this.setState({backgroundPane: }); + case 8: + return this.setState({backgroundPane: }); + case 10: + return this.setState({backgroundPane: null}); + default: + return; + } + }, + }; + this.typed = new Typed(this.el, options); + } + + componentWillUnmount() { + this.typed.destroy(); + } + + render() { + return ( +
+
+
+
+ { + this.el = el; + }}/> +
+
+
+
+ {this.state.backgroundPane} +
+
+ ); + } + +} + +export default MainPane; diff --git a/frontend/src/components/panels/PcapsPane.jsx b/frontend/src/components/panels/PcapsPane.jsx new file mode 100644 index 0000000..b7d5ce9 --- /dev/null +++ b/frontend/src/components/panels/PcapsPane.jsx @@ -0,0 +1,287 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React, {Component} from "react"; +import Table from "react-bootstrap/Table"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import {createCurlCommand, dateTimeToTime, durationBetween, formatSize} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import CopyLinkPopover from "../objects/CopyLinkPopover"; +import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./PcapsPane.scss"; + +class PcapsPane extends Component { + + state = { + sessions: [], + isUploadFileValid: true, + isUploadFileFocused: false, + uploadFlushAll: false, + isFileValid: true, + isFileFocused: false, + fileValue: "", + processFlushAll: false, + deleteOriginalFile: false + }; + + componentDidMount() { + this.loadSessions(); + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/pcaps$"; + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (payload) => { + if (payload.event.startsWith("pcap")) { + this.loadSessions(); + } + }; + + loadSessions = () => { + backend.get("/api/pcap/sessions") + .then((res) => this.setState({sessions: res.json, sessionsStatusCode: res.status})) + .catch((res) => this.setState({ + sessions: res.json, sessionsStatusCode: res.status, + sessionsResponse: JSON.stringify(res.json) + })); + }; + + uploadPcap = () => { + if (this.state.uploadSelectedFile == null || !this.state.isUploadFileValid) { + this.setState({isUploadFileFocused: true}); + return; + } + + const formData = new FormData(); + formData.append("file", this.state.uploadSelectedFile); + formData.append("flush_all", this.state.uploadFlushAll); + backend.postFile("/api/pcap/upload", formData).then((res) => { + this.setState({ + uploadStatusCode: res.status, + uploadResponse: JSON.stringify(res.json) + }); + this.resetUpload(); + this.loadSessions(); + }).catch((res) => this.setState({ + uploadStatusCode: res.status, + uploadResponse: JSON.stringify(res.json) + }) + ); + }; + + processPcap = () => { + if (this.state.fileValue === "" || !this.state.isFileValid) { + this.setState({isFileFocused: true}); + return; + } + + backend.post("/api/pcap/file", { + "file": this.state.fileValue, + "flush_all": this.state.processFlushAll, + "delete_original_file": this.state.deleteOriginalFile + }).then((res) => { + this.setState({ + processStatusCode: res.status, + processResponse: JSON.stringify(res.json) + }); + this.resetProcess(); + this.loadSessions(); + }).catch((res) => this.setState({ + processStatusCode: res.status, + processResponse: JSON.stringify(res.json) + }) + ); + }; + + resetUpload = () => { + this.setState({ + isUploadFileValid: true, + isUploadFileFocused: false, + uploadFlushAll: false, + uploadSelectedFile: null + }); + }; + + resetProcess = () => { + this.setState({ + isFileValid: true, + isFileFocused: false, + fileValue: "", + processFlushAll: false, + deleteOriginalFile: false, + }); + }; + + render() { + let sessions = this.state.sessions.map((s) => { + const startedAt = new Date(s["started_at"]); + const completedAt = new Date(s["completed_at"]); + let timeInfo =
+ Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}
+ Completed at {completedAt.toLocaleDateString() + " " + completedAt.toLocaleTimeString()} +
; + + return + + + + + {durationBetween(s["started_at"], s["completed_at"])} + {formatSize(s["size"])} + {s["processed_packets"]} + {s["invalid_packets"]} + + download + + ; + }); + + const handleUploadFileChange = (file) => { + this.setState({ + isUploadFileValid: file == null || (file.type.endsWith("pcap") || file.type.endsWith("pcapng")), + isUploadFileFocused: false, + uploadSelectedFile: file, + uploadStatusCode: null, + uploadResponse: null + }); + }; + + const handleFileChange = (file) => { + this.setState({ + isFileValid: (file.endsWith("pcap") || file.endsWith("pcapng")), + isFileFocused: false, + fileValue: file, + processStatusCode: null, + processResponse: null + }); + }; + + const uploadCurlCommand = createCurlCommand("/pcap/upload", "POST", null, { + "file": "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ? + this.state.uploadSelectedFile.name : "invalid.pcap"), + "flush_all": this.state.uploadFlushAll + }); + + const fileCurlCommand = createCurlCommand("/pcap/file", "POST", { + "file": this.state.fileValue, + "flush_all": this.state.processFlushAll, + "delete_original_file": this.state.deleteOriginalFile + }); + + return ( +
+
+
+ GET /api/pcap/sessions + +
+ +
+
+ + + + + + + + + + + + + + + {sessions} + +
idstarted_atdurationsizeprocessed_packetsinvalid_packetspackets_per_serviceactions
+
+
+
+ +
+
+
+ POST /api/pcap/upload + +
+ +
+ +
+
+ options: + this.setState({uploadFlushAll: v})}/> +
+ +
+ + +
+
+ +
+
+ POST /api/pcap/file + +
+ +
+ + +
+
+ this.setState({processFlushAll: v})}/> + this.setState({deleteOriginalFile: v})}/> +
+ +
+ + +
+
+
+
+ ); + } +} + +export default PcapsPane; diff --git a/frontend/src/components/panels/RulesPane.jsx b/frontend/src/components/panels/RulesPane.jsx new file mode 100644 index 0000000..4cb5e41 --- /dev/null +++ b/frontend/src/components/panels/RulesPane.jsx @@ -0,0 +1,469 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React, { Component } from "react"; +import { Col, Container, Row } from "react-bootstrap"; +import Table from "react-bootstrap/Table"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import validation from "../../validation"; +import ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import ChoiceField from "../fields/ChoiceField"; +import ColorField from "../fields/extensions/ColorField"; +import NumericField from "../fields/extensions/NumericField"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import CopyLinkPopover from "../objects/CopyLinkPopover"; +import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./RulesPane.scss"; + +import classNames from 'classnames'; +import _ from 'lodash'; + +class RulesPane extends Component { + + emptyRule = { + "name": "", + "color": "", + "notes": "", + "enabled": true, + "patterns": [], + "filter": { + "service_port": 0, + "client_address": "", + "client_port": 0, + "min_duration": 0, + "max_duration": 0, + "min_bytes": 0, + "max_bytes": 0 + }, + "version": 0 + }; + emptyPattern = { + "regex": "", + "flags": { + "caseless": false, + "dot_all": false, + "multi_line": false, + "utf_8_mode": false, + "unicode_property": false + }, + "min_occurrences": 0, + "max_occurrences": 0, + "direction": 0 + }; + state = { + rules: [], + newRule: this.emptyRule, + newPattern: this.emptyPattern + }; + + constructor(props) { + super(props); + + this.directions = { + 0: "both", + 1: "c->s", + 2: "s->c" + }; + } + + componentDidMount() { + this.reset(); + this.loadRules(); + + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/rules$"; + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (payload) => { + if (payload.event === "rules.new" || payload.event === "rules.edit") { + this.loadRules(); + } + }; + + loadRules = () => { + backend.get("/api/rules").then((res) => this.setState({ rules: res.json, rulesStatusCode: res.status })) + .catch((res) => this.setState({ rulesStatusCode: res.status, rulesResponse: JSON.stringify(res.json) })); + }; + + addRule = () => { + if (this.validateRule(this.state.newRule)) { + backend.post("/api/rules", this.state.newRule).then((res) => { + this.reset(); + this.setState({ ruleStatusCode: res.status }); + this.loadRules(); + }).catch((res) => { + this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) }); + }); + } + }; + + deleteRule = () => { + const rule = this.state.selectedRule; + backend.delete(`/api/rules/${rule.id}`).then((res) => { + this.reset(); + this.setState({ ruleStatusCode: res.status }); + this.loadRules(); + }).catch((res) => { + this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) }); + }); + } + + updateRule = () => { + const rule = this.state.selectedRule; + if (this.validateRule(rule)) { + backend.put(`/api/rules/${rule.id}`, rule).then((res) => { + this.reset(); + this.setState({ ruleStatusCode: res.status }); + this.loadRules(); + }).catch((res) => { + this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) }); + }); + } + }; + + validateRule = (rule) => { + let valid = true; + if (rule.name.length < 3) { + this.setState({ ruleNameError: "name.length < 3" }); + valid = false; + } + if (!validation.isValidColor(rule.color)) { + this.setState({ ruleColorError: "color is not hexcolor" }); + valid = false; + } + if (!validation.isValidPort(rule.filter["service_port"])) { + this.setState({ ruleServicePortError: "service_port > 65565" }); + valid = false; + } + if (!validation.isValidPort(rule.filter["client_port"])) { + this.setState({ ruleClientPortError: "client_port > 65565" }); + valid = false; + } + if (!validation.isValidAddress(rule.filter["client_address"])) { + this.setState({ ruleClientAddressError: "client_address is not ip_address" }); + valid = false; + } + if (rule.filter["min_duration"] > rule.filter["max_duration"]) { + this.setState({ ruleDurationError: "min_duration > max_dur." }); + valid = false; + } + if (rule.filter["min_bytes"] > rule.filter["max_bytes"]) { + this.setState({ ruleBytesError: "min_bytes > max_bytes" }); + valid = false; + } + if (rule.patterns.length < 1) { + this.setState({ rulePatternsError: "patterns.length < 1" }); + valid = false; + } + + return valid; + }; + + reset = () => { + const newRule = _.cloneDeep(this.emptyRule); + const newPattern = _.cloneDeep(this.emptyPattern); + this.setState({ + selectedRule: null, + newRule, + selectedPattern: null, + newPattern, + patternRegexFocused: false, + patternOccurrencesFocused: false, + ruleNameError: null, + ruleColorError: null, + ruleServicePortError: null, + ruleClientPortError: null, + ruleClientAddressError: null, + ruleDurationError: null, + ruleBytesError: null, + rulePatternsError: null, + ruleStatusCode: null, + rulesStatusCode: null, + ruleResponse: null, + rulesResponse: null + }); + }; + + updateParam = (callback) => { + const updatedRule = this.currentRule(); + callback(updatedRule); + this.setState({ newRule: updatedRule }); + }; + + currentRule = () => this.state.selectedRule != null ? this.state.selectedRule : this.state.newRule; + + addPattern = (pattern) => { + if (!this.validatePattern(pattern)) { + return; + } + + const newPattern = _.cloneDeep(this.emptyPattern); + this.currentRule().patterns.push(pattern); + this.setState({ newPattern }); + }; + + editPattern = (pattern) => { + this.setState({ + selectedPattern: pattern + }); + }; + + updatePattern = (pattern) => { + if (!this.validatePattern(pattern)) { + return; + } + + this.setState({ + selectedPattern: null + }); + }; + + validatePattern = (pattern) => { + let valid = true; + if (pattern.regex === "") { + valid = false; + this.setState({ patternRegexFocused: true }); + } + if (pattern["min_occurrences"] > pattern["max_occurrences"]) { + valid = false; + this.setState({ patternOccurrencesFocused: true }); + } + return valid; + }; + + render() { + const isUpdate = this.state.selectedRule != null; + const rule = this.currentRule(); + const pattern = this.state.selectedPattern || this.state.newPattern; + + let rules = this.state.rules.map((r) => + { + this.reset(); + this.setState({ selectedRule: _.cloneDeep(r) }); + }} className={classNames("row-small", "row-clickable", { "row-selected": rule.id === r.id })}> + + {r["name"]} + + {r["notes"]} + + ); + + let patterns = (this.state.selectedPattern == null && !isUpdate ? + rule.patterns.concat(this.state.newPattern) : + rule.patterns + ).map((p) => p === pattern ? + + + { + this.updateParam(() => pattern.regex = v); + this.setState({ patternRegexFocused: pattern.regex === "" }); + }} /> + + this.updateParam(() => pattern.flags["caseless"] = v)} /> + this.updateParam(() => pattern.flags["dot_all"] = v)} /> + this.updateParam(() => pattern.flags["multi_line"] = v)} /> + this.updateParam(() => pattern.flags["utf_8_mode"] = v)} /> + this.updateParam(() => pattern.flags["unicode_property"] = v)} /> + + this.updateParam(() => pattern["min_occurrences"] = v)} /> + + + this.updateParam(() => pattern["max_occurrences"] = v)} /> + + s", "s->c"]} + value={this.directions[pattern.direction]} + onChange={(v) => this.updateParam(() => pattern.direction = v)} /> + {this.state.selectedPattern == null ? + this.addPattern(p)} /> : + this.updatePattern(p)} />} + + + : + + {p.regex} + {p.flags["caseless"] ? "yes" : "no"} + {p.flags["dot_all"] ? "yes" : "no"} + {p.flags["multi_line"] ? "yes" : "no"} + {p.flags["utf_8_mode"] ? "yes" : "no"} + {p.flags["unicode_property"] ? "yes" : "no"} + {p["min_occurrences"]} + {p["max_occurrences"]} + {this.directions[p.direction]} + + this.editPattern(p)} + /> + + + ); + + return ( +
+
+
+ GET /api/rules + {this.state.rulesStatusCode && + } +
+ +
+
+ + + + + + + + + + + {rules} + +
idnamecolornotes
+
+
+
+ +
+
+ + {isUpdate ? `PUT /api/rules/${this.state.selectedRule.id}` : "POST /api/rules"} + + +
+ +
+ + + + this.updateParam((r) => r.name = v)} + error={this.state.ruleNameError} /> + this.updateParam((r) => r.color = v)} /> + this.updateParam((r) => r.notes = v)} /> + + + + filters: + this.updateParam((r) => r.filter["service_port"] = v)} + min={0} + max={65565} + error={this.state.ruleServicePortError} + /> + this.updateParam((r) => r.filter["client_port"] = v)} + min={0} + max={65565} + error={this.state.ruleClientPortError} + /> + this.updateParam((r) => r.filter["client_address"] = v)} /> + + + + this.updateParam((r) => r.filter["min_duration"] = v)} /> + this.updateParam((r) => r.filter["max_duration"] = v)} /> + this.updateParam((r) => r.filter["min_bytes"] = v)} /> + this.updateParam((r) => r.filter["max_bytes"] = v)} /> + + + + +
+ + + + + + + + + + + + + {!isUpdate && } + + + + {patterns} + +
regex!Aa.*\n+UTF8Uni_minmaxdirectionactions
+ {this.state.rulePatternsError != null && + error: {this.state.rulePatternsError}} +
+
+ +
+ {} + + +
+
+
+ ); + } + +} + +export default RulesPane; diff --git a/frontend/src/components/panels/SearchPane.jsx b/frontend/src/components/panels/SearchPane.jsx new file mode 100644 index 0000000..6fe9dc7 --- /dev/null +++ b/frontend/src/components/panels/SearchPane.jsx @@ -0,0 +1,309 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React, {Component} from "react"; +import Table from "react-bootstrap/Table"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import {createCurlCommand, dateTimeToTime, durationBetween} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import InputField from "../fields/InputField"; +import TagField from "../fields/TagField"; +import TextField from "../fields/TextField"; +import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./SearchPane.scss"; + +import _ from 'lodash'; + +class SearchPane extends Component { + + searchOptions = { + "text_search": { + "terms": null, + "excluded_terms": null, + "exact_phrase": "", + "case_sensitive": false + }, + "regex_search": { + "pattern": "", + "not_pattern": "", + "case_insensitive": false, + "multi_line": false, + "ignore_whitespaces": false, + "dot_character": false + }, + "timeout": 10 + }; + + state = { + searches: [], + currentSearchOptions: this.searchOptions, + }; + + componentDidMount() { + this.reset(); + this.loadSearches(); + + dispatcher.register("notifications", this.handleNotification); + document.title = "caronte:~/searches$"; + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotification); + } + + loadSearches = () => { + backend.get("/api/searches") + .then((res) => this.setState({searches: res.json, searchesStatusCode: res.status})) + .catch((res) => this.setState({searchesStatusCode: res.status, searchesResponse: JSON.stringify(res.json)})); + }; + + performSearch = () => { + const options = this.state.currentSearchOptions; + this.setState({loading: true}); + if (this.validateSearch(options)) { + backend.post("/api/searches/perform", options).then((res) => { + this.reset(); + this.setState({searchStatusCode: res.status, loading: false}); + this.loadSearches(); + this.viewSearch(res.json.id); + }).catch((res) => { + this.setState({ + searchStatusCode: res.status, searchResponse: JSON.stringify(res.json), + loading: false + }); + }); + } + }; + + reset = () => { + this.setState({ + currentSearchOptions: _.cloneDeep(this.searchOptions), + exactPhraseError: null, + patternError: null, + notPatternError: null, + searchStatusCode: null, + searchesStatusCode: null, + searchResponse: null, + searchesResponse: null + }); + }; + + validateSearch = (options) => { + let valid = true; + if (options["text_search"]["exact_phrase"] && options["text_search"]["exact_phrase"].length < 3) { + this.setState({exactPhraseError: "text_search.exact_phrase.length < 3"}); + valid = false; + } + if (options["regex_search"].pattern && options["regex_search"].pattern.length < 3) { + this.setState({patternError: "regex_search.pattern.length < 3"}); + valid = false; + } + if (options["regex_search"]["not_pattern"] && options["regex_search"]["not_pattern"].length < 3) { + this.setState({exactPhraseError: "regex_search.not_pattern.length < 3"}); + valid = false; + } + + return valid; + }; + + updateParam = (callback) => { + callback(this.state.currentSearchOptions); + this.setState({currentSearchOptions: this.state.currentSearchOptions}); + }; + + extractPattern = (options) => { + let pattern = ""; + if (_.isEqual(options.regex_search, this.searchOptions.regex_search)) { // is text search + if (options["text_search"]["exact_phrase"]) { + pattern += `"${options["text_search"]["exact_phrase"]}"`; + } else { + pattern += options["text_search"].terms.join(" "); + if (options["text_search"]["excluded_terms"]) { + pattern += " -" + options["text_search"]["excluded_terms"].join(" -"); + } + } + options["text_search"]["case_sensitive"] && (pattern += "/s"); + } else { // is regex search + if (options["regex_search"].pattern) { + pattern += "/" + options["regex_search"].pattern + "/"; + } else { + pattern += "!/" + options["regex_search"]["not_pattern"] + "/"; + } + options["regex_search"]["case_insensitive"] && (pattern += "i"); + options["regex_search"]["multi_line"] && (pattern += "m"); + options["regex_search"]["ignore_whitespaces"] && (pattern += "x"); + options["regex_search"]["dot_character"] && (pattern += "s"); + } + + return pattern; + }; + + viewSearch = (searchId) => { + dispatcher.dispatch("connections_filters", {"performed_search": searchId}); + }; + + handleNotification = (payload) => { + if (payload.event === "searches.new") { + this.loadSearches(); + } + }; + + render() { + const options = this.state.currentSearchOptions; + + let searches = this.state.searches.map((s) => + + {s.id.substring(0, 8)} + {this.extractPattern(s["search_options"])} + {s["affected_connections_count"]} + {dateTimeToTime(s["started_at"])} + {durationBetween(s["started_at"], s["finished_at"])} + this.viewSearch(s.id)}/> + + ); + + const textOptionsModified = !_.isEqual(this.searchOptions.text_search, options.text_search); + const regexOptionsModified = !_.isEqual(this.searchOptions.regex_search, options.regex_search); + + const curlCommand = createCurlCommand("/searches/perform", "POST", options); + + return ( +
+
+
+ GET /api/searches + {this.state.searchesStatusCode && + } +
+ +
+
+ + + + + + + + + + + + + {searches} + +
idpatternoccurrencesstarted_atdurationactions
+
+
+
+ +
+
+ POST /api/searches/perform + +
+ +
+ + NOTE: it is recommended to use the rules for recurring themes. Give preference to textual search over that with regex. + + +
+
+ { + return {name: t}; + })} + name="terms" min={3} inline allowNew={true} + readonly={regexOptionsModified || options["text_search"]["exact_phrase"]} + onChange={(tags) => this.updateParam((s) => s["text_search"].terms = tags.map((t) => t.name))}/> + { + return {name: t}; + })} + name="excluded_terms" min={3} inline allowNew={true} + readonly={regexOptionsModified || options["text_search"]["exact_phrase"]} + onChange={(tags) => this.updateParam((s) => s["text_search"]["excluded_terms"] = tags.map((t) => t.name))}/> + + or + + this.updateParam((s) => s["text_search"]["exact_phrase"] = v)} + readonly={regexOptionsModified || (Array.isArray(options["text_search"].terms) && options["text_search"].terms.length > 0)}/> + + this.updateParam((s) => s["text_search"]["case_sensitive"] = v)}/> +
+ +
+ or +
+ +
+ this.updateParam((s) => s["regex_search"].pattern = v)}/> + or + this.updateParam((s) => s["regex_search"]["not_pattern"] = v)}/> + +
+ this.updateParam((s) => s["regex_search"]["case_insensitive"] = v)}/> + this.updateParam((s) => s["regex_search"]["multi_line"] = v)}/> + this.updateParam((s) => s["regex_search"]["ignore_whitespaces"] = v)}/> + this.updateParam((s) => s["regex_search"]["dot_character"] = v)}/> +
+
+
+ + +
+ +
+ + +
+
+
+ ); + } + +} + +export default SearchPane; diff --git a/frontend/src/components/panels/ServicesPane.jsx b/frontend/src/components/panels/ServicesPane.jsx new file mode 100644 index 0000000..296b329 --- /dev/null +++ b/frontend/src/components/panels/ServicesPane.jsx @@ -0,0 +1,233 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React, {Component} from "react"; +import {Col, Container, Row} from "react-bootstrap"; +import Table from "react-bootstrap/Table"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import {createCurlCommand} from "../../utils"; +import validation from "../../validation"; +import ButtonField from "../fields/ButtonField"; +import ColorField from "../fields/extensions/ColorField"; +import NumericField from "../fields/extensions/NumericField"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./ServicesPane.scss"; + +import classNames from 'classnames'; +import _ from 'lodash'; + +class ServicesPane extends Component { + + emptyService = { + "port": 0, + "name": "", + "color": "", + "notes": "" + }; + + state = { + services: [], + currentService: this.emptyService, + }; + + componentDidMount() { + this.reset(); + this.loadServices(); + + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/services$"; + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + } + + handleNotifications = (payload) => { + if (payload.event === "services.edit") { + this.loadServices(); + } + }; + + loadServices = () => { + backend.get("/api/services") + .then((res) => this.setState({services: Object.values(res.json), servicesStatusCode: res.status})) + .catch((res) => this.setState({servicesStatusCode: res.status, servicesResponse: JSON.stringify(res.json)})); + }; + + updateService = () => { + const service = this.state.currentService; + if (this.validateService(service)) { + backend.put("/api/services", service).then((res) => { + this.reset(); + this.setState({serviceStatusCode: res.status}); + this.loadServices(); + }).catch((res) => { + this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)}); + }); + } + }; + + deleteService = () => { + const service = this.state.currentService; + if (this.validateService(service)) { + backend.delete("/api/services", service).then((res) => { + this.reset(); + this.setState({serviceStatusCode: res.status}); + this.loadServices(); + }).catch((res) => { + this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)}); + }); + } + }; + + validateService = (service) => { + let valid = true; + if (!validation.isValidPort(service.port, true)) { + this.setState({servicePortError: "port < 0 || port > 65565"}); + valid = false; + } + if (service.name.length < 3) { + this.setState({serviceNameError: "name.length < 3"}); + valid = false; + } + if (!validation.isValidColor(service.color)) { + this.setState({serviceColorError: "color is not hexcolor"}); + valid = false; + } + + return valid; + }; + + reset = () => { + this.setState({ + isUpdate: false, + currentService: _.cloneDeep(this.emptyService), + servicePortError: null, + serviceNameError: null, + serviceColorError: null, + serviceStatusCode: null, + servicesStatusCode: null, + serviceResponse: null, + servicesResponse: null + }); + }; + + updateParam = (callback) => { + callback(this.state.currentService); + this.setState({currentService: this.state.currentService}); + }; + + render() { + const isUpdate = this.state.isUpdate; + const service = this.state.currentService; + + let services = this.state.services.map((s) => + { + this.reset(); + this.setState({isUpdate: true, currentService: _.cloneDeep(s)}); + }} className={classNames("row-small", "row-clickable", {"row-selected": service.port === s.port})}> + {s["port"]} + {s["name"]} + + {s["notes"]} + + ); + + const curlCommand = createCurlCommand("/services", "PUT", service); + + return ( +
+
+
+ GET /api/services + {this.state.servicesStatusCode && + } +
+ +
+
+ + + + + + + + + + + {services} + +
portnamecolornotes
+
+
+
+ +
+
+ PUT /api/services + +
+ +
+ + + + this.updateParam((s) => s.port = v)} + min={0} max={65565} error={this.state.servicePortError}/> + this.updateParam((s) => s.name = v)} + error={this.state.serviceNameError}/> + this.updateParam((s) => s.color = v)}/> + + + + this.updateParam((s) => s.notes = v)}/> + + + + + +
+ +
+ {} + {isUpdate && } + +
+
+
+ ); + } + +} + +export default ServicesPane; diff --git a/frontend/src/components/panels/StatsPane.jsx b/frontend/src/components/panels/StatsPane.jsx new file mode 100644 index 0000000..a35ef0c --- /dev/null +++ b/frontend/src/components/panels/StatsPane.jsx @@ -0,0 +1,274 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React, {Component} from "react"; +import Table from "react-bootstrap/Table"; +import backend from "../../backend"; +import dispatcher from "../../dispatcher"; +import {formatSize} from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import CopyLinkPopover from "../objects/CopyLinkPopover"; +import LinkPopover from "../objects/LinkPopover"; +import "./common.scss"; +import "./StatsPane.scss"; + +class StatsPane extends Component { + + state = { + rules: [] + }; + + componentDidMount() { + this.loadStats(); + this.loadResourcesStats(); + this.loadRules(); + dispatcher.register("notifications", this.handleNotifications); + document.title = "caronte:~/stats$"; + this.intervalToken = setInterval(() => this.loadResourcesStats(), 3000); + } + + componentWillUnmount() { + dispatcher.unregister(this.handleNotifications); + clearInterval(this.intervalToken); + } + + handleNotifications = (payload) => { + if (payload.event.startsWith("pcap")) { + this.loadStats(); + } else if (payload.event.startsWith("rules")) { + this.loadRules(); + } + }; + + loadStats = () => { + backend.get("/api/statistics/totals") + .then((res) => this.setState({stats: res.json, statsStatusCode: res.status})) + .catch((res) => this.setState({ + stats: res.json, statsStatusCode: res.status, + statsResponse: JSON.stringify(res.json) + })); + }; + + loadResourcesStats = () => { + backend.get("/api/resources/system") + .then((res) => this.setState({resourcesStats: res.json, resourcesStatsStatusCode: res.status})) + .catch((res) => this.setState({ + resourcesStats: res.json, resourcesStatsStatusCode: res.status, + resourcesStatsResponse: JSON.stringify(res.json) + })); + }; + + loadRules = () => { + backend.get("/api/rules").then((res) => this.setState({rules: res.json})); + }; + + render() { + const s = this.state.stats; + const rs = this.state.resourcesStats; + + const ports = s && s["connections_per_service"] ? Object.keys(s["connections_per_service"]) : []; + let connections = 0, clientBytes = 0, serverBytes = 0, totalBytes = 0, duration = 0; + let servicesStats = ports.map((port) => { + connections += s["connections_per_service"][port]; + clientBytes += s["client_bytes_per_service"][port]; + serverBytes += s["server_bytes_per_service"][port]; + totalBytes += s["total_bytes_per_service"][port]; + duration += s["duration_per_service"][port]; + + return + {port} + {formatSize(s["connections_per_service"][port])} + {formatSize(s["client_bytes_per_service"][port])}B + {formatSize(s["server_bytes_per_service"][port])}B + {formatSize(s["total_bytes_per_service"][port])}B + {formatSize(s["duration_per_service"][port] / 1000)}s + ; + }); + servicesStats.push( + totals + {formatSize(connections)} + {formatSize(clientBytes)}B + {formatSize(serverBytes)}B + {formatSize(totalBytes)}B + {formatSize(duration / 1000)}s + ); + + const rulesStats = this.state.rules.map((r) => + + + {r["name"]} + + {formatSize(s && s["matched_rules"] && s["matched_rules"][r.id] ? s["matched_rules"][r.id] : 0)} + + ); + + const cpuStats = (rs ? rs["cpu_times"] : []).map((cpu, index) => + + {cpu["cpu"]} + {cpu["user"]} + {cpu["system"]} + {cpu["idle"]} + {cpu["nice"]} + {cpu["iowait"]} + {rs["cpu_percents"][index].toFixed(2)} % + + ); + + return ( +
+
+
+ GET /api/statistics/totals + +
+ +
+
+ + + + + + + + + + + + + {servicesStats} + +
serviceconnectionsclient_bytesserver_bytestotal_bytesduration
+
+ +
+ + + + + + + + + + + {rulesStats} + +
rule_idrule_namerule_coloroccurrences
+
+
+
+ +
+
+ GET /api/resources/system + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
typetotalusedfreesharedbuff/cacheavailable
mem{rs && formatSize(rs["virtual_memory"]["total"])}{rs && formatSize(rs["virtual_memory"]["used"])}{rs && formatSize(rs["virtual_memory"]["free"])}{rs && formatSize(rs["virtual_memory"]["shared"])}{rs && formatSize(rs["virtual_memory"]["cached"])}{rs && formatSize(rs["virtual_memory"]["available"])}
swap{rs && formatSize(rs["virtual_memory"]["swaptotal"])}{rs && formatSize(rs["virtual_memory"]["swaptotal"])}{rs && formatSize(rs["virtual_memory"]["swapfree"])}---
+
+ +
+ + + + + + + + + + + + + + {cpuStats} + +
cpuusersystemidleniceiowaitused_percent
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
disk_pathfs_typetotalfreeusedused_percent
{rs && rs["disk_usage"]["path"]}{rs && rs["disk_usage"]["fstype"]}{rs && formatSize(rs["disk_usage"]["total"])}{rs && formatSize(rs["disk_usage"]["free"])}{rs && formatSize(rs["disk_usage"]["used"])}{rs && rs["disk_usage"]["usedPercent"].toFixed(2)} %
+
+
+
+
+ ); + } + +} + +export default StatsPane; diff --git a/frontend/src/components/panels/StreamsPane.jsx b/frontend/src/components/panels/StreamsPane.jsx new file mode 100644 index 0000000..9e88f55 --- /dev/null +++ b/frontend/src/components/panels/StreamsPane.jsx @@ -0,0 +1,453 @@ +/* + * This file is part of caronte (https://github.com/eciavatta/caronte). + * Copyright (c) 2020 Emiliano Ciavatta. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import DOMPurify from "dompurify"; +import React, { Component } from "react"; +import { Row } from "react-bootstrap"; +import ReactJson from "react-json-view"; +import backend from "../../backend"; +import log from "../../log"; +import rules from "../../model/rules"; +import { downloadBlob, getHeaderValue } from "../../utils"; +import ButtonField from "../fields/ButtonField"; +import ChoiceField from "../fields/ChoiceField"; +import CopyDialog from "../dialogs/CopyDialog"; +import "./StreamsPane.scss"; + +import reactStringReplace from "react-string-replace"; +import classNames from "classnames"; + +class StreamsPane extends Component { + state = { + messages: [], + format: "default", + tryParse: true, + }; + + constructor(props) { + super(props); + + this.validFormats = [ + "default", + "hex", + "hexdump", + "base32", + "base64", + "ascii", + "binary", + "decimal", + "octal", + ]; + } + + componentDidMount() { + if ( + this.props.connection && + this.state.currentId !== this.props.connection.id + ) { + this.setState({ currentId: this.props.connection.id }); + this.loadStream(this.props.connection.id); + } + + document.title = "caronte:~/$"; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if ( + this.props.connection && + (this.props.connection !== prevProps.connection || + this.state.format !== prevState.format) + ) { + this.closeRenderWindow(); + this.loadStream(this.props.connection.id); + } + } + + componentWillUnmount() { + this.closeRenderWindow(); + } + + loadStream = (connectionId) => { + this.setState({ messages: [], currentId: connectionId }); + backend + .get(`/api/streams/${connectionId}?format=${this.state.format}`) + .then((res) => this.setState({ messages: res.json })); + }; + + setFormat = (format) => { + if (this.validFormats.includes(format)) { + this.setState({ format }); + } + }; + + viewAs = (mode) => { + if (mode === "decoded") { + this.setState({ tryParse: true }); + } else if (mode === "raw") { + this.setState({ tryParse: false }); + } + }; + + tryParseConnectionMessage = (connectionMessage) => { + const isClient = connectionMessage["from_client"]; + if (connectionMessage.metadata == null) { + return this.highlightRules(connectionMessage.content, isClient); + } + + let unrollMap = (obj) => + obj == null + ? null + : Object.entries(obj).map(([key, value]) => ( +

+ {key}: {value} +

+ )); + + let m = connectionMessage.metadata; + switch (m.type) { + case "http-request": + let url = ( + + + + {m.host} + {m.url} + + + + ); + return ( + +

+ {m.method} {url} {m.protocol} +

+ {unrollMap(m.headers)} +
+ {this.highlightRules(m.body, isClient)} +
+ {unrollMap(m.trailers)} +
+ ); + case "http-response": + const contentType = getHeaderValue(m, "Content-Type"); + let body = m.body; + if (contentType && contentType.includes("application/json")) { + try { + const json = JSON.parse(m.body); + if (typeof json === "object") { + body = ( + + ); + } + } catch (e) { + log.error(e); + } + } + + return ( + +

+ {m.protocol} {m.status} +

+ {unrollMap(m.headers)} +
+ {this.highlightRules(body, isClient)} +
+ {unrollMap(m.trailers)} +
+ ); + default: + return this.highlightRules(connectionMessage.content, isClient); + } + }; + + highlightRules = (content, isClient) => { + let streamContent = content; + this.props.connection["matched_rules"].forEach((ruleId) => { + const rule = rules.ruleById(ruleId); + rule.patterns.forEach((pattern) => { + if ( + (!isClient && pattern.direction === 1) || + (isClient && pattern.direction === 2) + ) { + return; + } + let flags = ""; + pattern["caseless"] && (flags += "i"); + pattern["dot_all"] && (flags += "s"); + pattern["multi_line"] && (flags += "m"); + pattern["unicode_property"] && (flags += "u"); + const regex = new RegExp( + pattern.regex.replace(/^\//, "(").replace(/\/$/, ")"), + flags + ); + streamContent = reactStringReplace(streamContent, regex, (match, i) => ( + + {match} + + )); + }); + }); + + return streamContent; + }; + + connectionsActions = (connectionMessage) => { + if (!connectionMessage.metadata) { + return null; + } + + const m = connectionMessage.metadata; + switch (m.type) { + case "http-request": + if (!connectionMessage.metadata["reproducers"]) { + return; + } + return Object.entries(connectionMessage.metadata["reproducers"]).map( + ([name, value]) => ( + { + this.setState({ + messageActionDialog: ( + + this.setState({ messageActionDialog: null }) + } + /> + ), + }); + }} + /> + ) + ); + case "http-response": + const contentType = getHeaderValue(m, "Content-Type"); + + if (contentType && contentType.includes("text/html")) { + return ( + { + let w; + if ( + this.state.renderWindow && + !this.state.renderWindow.closed + ) { + w = this.state.renderWindow; + } else { + w = window.open( + "", + "", + "width=900, height=600, scrollbars=yes" + ); + this.setState({ renderWindow: w }); + } + w.document.body.innerHTML = DOMPurify.sanitize(m.body); + w.focus(); + }} + /> + ); + } + break; + default: + return null; + } + }; + + downloadStreamRaw = (value) => { + if (this.state.currentId) { + backend + .download( + `/api/streams/${this.props.connection.id}/download?format=${this.state.format}&type=${value}` + ) + .then((res) => + downloadBlob( + res.blob, + `${this.state.currentId}-${value}-${this.state.format}.txt` + ) + ) + .catch((_) => log.error("Failed to download stream messages")); + } + }; + + closeRenderWindow = () => { + if (this.state.renderWindow) { + this.state.renderWindow.close(); + } + }; + + render() { + const conn = this.props.connection || { + ip_src: "0.0.0.0", + ip_dst: "0.0.0.0", + port_src: "0", + port_dst: "0", + started_at: new Date().toISOString(), + }; + const content = this.state.messages || []; + + let payload = content + .filter( + (c) => + !this.state.tryParse || + (this.state.tryParse && !c["is_metadata_continuation"]) + ) + .map((c, i) => ( +
+
+
+
+ + offset: {c.index} + {" "} + |{" "} + + timestamp: {c.timestamp} + {" "} + |{" "} + + retransmitted:{" "} + {c["is_retransmitted"] ? "yes" : "no"} + +
+
+ {this.connectionsActions(c)} +
+
+
+
+ {c["from_client"] ? "client" : "server"} +
+
+ {this.state.tryParse && this.state.format === "default" + ? this.tryParseConnectionMessage(c) + : c.content} +
+
+ )); + + return ( +
+
+ +
+ + flow: {conn["ip_src"]}:{conn["port_src"]} ->{" "} + {conn["ip_dst"]}:{conn["port_dst"]} + + + {" "} + | timestamp: {conn["started_at"]} + +
+
+ + + + + +
+
+
+ +
{payload}
+ {this.state.messageActionDialog} +
+ ); + } +} + +export default StreamsPane; -- cgit v1.2.3-70-g09d2