content
This commit is contained in:
parent
7ad360719c
commit
dbfedfc90a
7 changed files with 540 additions and 0 deletions
437
defs.py
Normal file
437
defs.py
Normal file
|
@ -0,0 +1,437 @@
|
|||
# definiciones
|
||||
from io import BytesIO
|
||||
import xlsxwriter
|
||||
import settings
|
||||
from pymisp import PyMISP
|
||||
import urllib3
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.application import MIMEApplication
|
||||
from crontab import CronTab
|
||||
|
||||
urllib3.disable_warnings()
|
||||
|
||||
class MISPAlertManager:
|
||||
|
||||
def __init__(self):
|
||||
self.dir_actual = os.getcwd()
|
||||
self.dir_logs = os.path.join(self.dir_actual, 'logs')
|
||||
self._setup_logging()
|
||||
self.misp = PyMISP(settings.MISP_CONFIG['URL_MISP'], settings.MISP_CONFIG['AUTHKEY'], False)
|
||||
|
||||
def _setup_logging(self):
|
||||
os.makedirs(self.dir_logs, exist_ok=True)
|
||||
log_file = os.path.join(self.dir_logs, f"alertas_{datetime.now().strftime('%Y%m%d')}.log")
|
||||
rotating_handler = RotatingFileHandler(log_file, maxBytes=262144000, backupCount=10)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
handlers=[rotating_handler],
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
def enviar_alerta(self):
|
||||
# realizado (flag)
|
||||
realizado = False
|
||||
|
||||
# Servidores
|
||||
servidores = []
|
||||
|
||||
# Se obtienen datos de conexión de servidores
|
||||
servidores_temp = self.obtener_servidores()
|
||||
|
||||
if servidores_temp:
|
||||
# Solo se quiere entregar desconectados, se filtra por los desconcetados
|
||||
if settings.SERVERS_OFF:
|
||||
for x in servidores_temp:
|
||||
if x['connection_status'] != 'Connected':
|
||||
servidores.append(x)
|
||||
else:
|
||||
# Entonces todos los servidores
|
||||
servidores = servidores_temp
|
||||
|
||||
|
||||
# Se verifica que servidores tenga datos
|
||||
if servidores:
|
||||
# Se arma estructura de correo
|
||||
try:
|
||||
# Version de Servidor de MISP
|
||||
misp_version = self.misp.misp_instance_version['version']
|
||||
|
||||
# Configuración de la cuenta de Office 365 y del servidor SMTP
|
||||
smtp_server = settings.EMAIL_CONFIG['server_smtp_host']
|
||||
smtp_port = settings.EMAIL_CONFIG['server_smtp_port']
|
||||
from_address = settings.EMAIL_CONFIG['smtp_username']
|
||||
to_address = settings.EMAIL_CONFIG['email_recipient']
|
||||
password = settings.EMAIL_CONFIG['smtp_password']
|
||||
|
||||
# Crear el mensaje
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = settings.EMAIL_CONFIG['smtp_username'] # Dirección 'from'
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = settings.EMAIL_CONFIG['email_subject']
|
||||
|
||||
# Cuerpo del correo en HTML inciala
|
||||
html_body_start = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<br>
|
||||
<p><h3>Local Version MISP :"""+misp_version+"""</h3></p>
|
||||
<table style="border: 1px solid black; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: #21120e;">Instance Name</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: #21120e;">Connection Status</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: #21120e;">Error Status</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: #21120e;">Remote Version</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: #21120e;">Remote Org</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: #21120e;">Status Code</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: #21120e;">Last Check</strong>
|
||||
</p>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
# HTML para datos
|
||||
html_body_data = ""
|
||||
|
||||
# Se arma HTML para servidores
|
||||
for serv in servidores:
|
||||
# Se verifica estado para aplicar color (Rojo / Verde)
|
||||
if serv['connection_status'] == 'Connected':
|
||||
html_body_data += """<tr>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+serv['instance_name']+"""
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: """+settings.EMAIL_CONFIG['output_color_status']+"""; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: """+settings.EMAIL_CONFIG['output_color_status']+""";">"""+serv['connection_status']+"""</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: """+settings.EMAIL_CONFIG['output_color_status']+"""; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: """+settings.EMAIL_CONFIG['output_color_status']+""";">"""+serv['error_status']+"""</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+serv['misp_remote_version']+"""
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+serv['remote_org']+"""
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+str(serv['status_code'])+"""
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+serv['last_check']+"""
|
||||
</p>
|
||||
</td>
|
||||
</tr>"""
|
||||
else:
|
||||
html_body_data += """<tr>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+serv['instance_name']+"""
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: """+settings.EMAIL_CONFIG['output_color_status_error']+"""; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: """+settings.EMAIL_CONFIG['output_color_status_error']+""";">"""+serv['connection_status']+"""</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: """+settings.EMAIL_CONFIG['output_color_status_error']+"""; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
<strong style="color: """+settings.EMAIL_CONFIG['output_color_status_error']+""";">"""+serv['error_status']+"""</strong>
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+serv['misp_remote_version']+"""
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+serv['remote_org']+"""
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+str(serv['status_code'])+"""
|
||||
</p>
|
||||
</td>
|
||||
<td style="padding-left:10px;">
|
||||
<p style="font-family: Arial, sans-serif; color: #21120e; font-size: 14px; line-height: 1.2; margin: 0;">
|
||||
"""+serv['last_check']+"""
|
||||
</p>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
# Final de HTML
|
||||
html_body_end = """
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# Ahora se junta todo el HTML
|
||||
html_fix = html_body_start+html_body_data+html_body_end
|
||||
|
||||
# Adjuntar el cuerpo HTML al mensaje
|
||||
msg.attach(MIMEText(html_fix, "html"))
|
||||
|
||||
|
||||
# Se verifica si se desea adjuntar archivo
|
||||
if settings.DATA_ATTACH:
|
||||
|
||||
# Se crear instancia de Workbook de Excel en memoria
|
||||
output = BytesIO()
|
||||
workbook = xlsxwriter.Workbook(output, {'in_memory': True}) # Crear el workbook en memoria
|
||||
worksheet = workbook.add_worksheet("Servers")
|
||||
|
||||
# Add a bold format to use to highlight cells.
|
||||
bold = workbook.add_format({'bold': 1})
|
||||
|
||||
# se escriben headers
|
||||
worksheet.write(0, 0, "Instance Name", bold)
|
||||
worksheet.write(0, 1, "Connection Status", bold)
|
||||
worksheet.write(0, 2, "Error Status", bold)
|
||||
worksheet.write(0, 3, "Remote Version", bold)
|
||||
worksheet.write(0, 4, "Remote Org", bold)
|
||||
worksheet.write(0, 5, "Status Code", bold)
|
||||
worksheet.write(0, 6, "Last Check", bold)
|
||||
|
||||
for row_index, servidor in enumerate(servidores, start=1):
|
||||
worksheet.write(row_index, 0, servidor['instance_name'])
|
||||
worksheet.write(row_index, 1, servidor['connection_status'])
|
||||
worksheet.write(row_index, 2, servidor['error_status'])
|
||||
worksheet.write(row_index, 3, servidor['misp_remote_version'])
|
||||
worksheet.write(row_index, 4, servidor['remote_org'])
|
||||
worksheet.write(row_index, 5, servidor['status_code'])
|
||||
worksheet.write(row_index, 6, servidor['last_check'])
|
||||
|
||||
|
||||
worksheet.autofit()
|
||||
workbook.close()
|
||||
output.seek(0)
|
||||
|
||||
# Adjuntar el archivo XLSX desde la memoria
|
||||
attached_file = MIMEApplication(output.read(), _subtype="xlsx")
|
||||
attached_file.add_header(
|
||||
'content-disposition',
|
||||
'attachment',
|
||||
filename=f'Servers_{datetime.now().strftime("%Y%m%d")}.xlsx'
|
||||
)
|
||||
msg.attach(attached_file)
|
||||
logging.info("Se creo Excel con datos para adjuntar a correo")
|
||||
|
||||
# Iniciar una conexión con el servidor SMTP de Office 365
|
||||
server = smtplib.SMTP(smtp_server, smtp_port)
|
||||
server.ehlo()
|
||||
|
||||
if settings.EMAIL_CONFIG['tls']:
|
||||
server.starttls() # Habilitar cifrado TLS
|
||||
|
||||
# Iniciar sesión en el servidor
|
||||
server.login(from_address, password)
|
||||
|
||||
# Enviar el correo
|
||||
server.sendmail(from_address, to_address, msg.as_string())
|
||||
|
||||
logging.info("Se envia correo a :"+to_address)
|
||||
realizado = True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(str(e))
|
||||
finally:
|
||||
# Cerrar la conexión con el servidor
|
||||
server.quit()
|
||||
else:
|
||||
logging.info("No existen servidores para hacer envio de alertas.")
|
||||
else:
|
||||
logging.info("No existen servidores para hacer envio de alertas.")
|
||||
|
||||
# return
|
||||
return realizado
|
||||
|
||||
|
||||
# Se obtiene lista de servidores de MISP
|
||||
def obtener_servidores(self):
|
||||
|
||||
# Get Severs
|
||||
servers = self.misp.servers(True)
|
||||
|
||||
# var Servers
|
||||
server_list = []
|
||||
|
||||
for s in servers:
|
||||
try:
|
||||
# Estructura de datos
|
||||
# data: instance_name, connection_status, status_log, misp_remote_version, remote_org, last_check
|
||||
temp = {}
|
||||
|
||||
# consultar remote_organisation
|
||||
rem_org = self.misp.get_organisation(s.remote_org_id, pythonify=True)
|
||||
|
||||
# Valor llaves por defecto
|
||||
temp['instance_name'] = s.name
|
||||
temp['connection_status'] = ''
|
||||
temp['error_status'] = ''
|
||||
temp['misp_remote_version'] = ''
|
||||
temp['remote_org'] = rem_org.name
|
||||
temp['status_code'] = 0
|
||||
|
||||
# Se verifica conexion
|
||||
server_temp = self.misp.test_server(s.id)
|
||||
|
||||
# timestamp
|
||||
temp['last_check'] = datetime.now(ZoneInfo("America/Santiago")).strftime("%Y-%m-%d %H:%M:%S %Z (%z)")
|
||||
|
||||
# status code de caso
|
||||
temp['status_code'] = server_temp['status']
|
||||
|
||||
# Se verifica estado de conexión
|
||||
if server_temp['status'] == 1:
|
||||
temp['connection_status'] = 'Connected'
|
||||
temp['error_status'] = 'OK. No error'
|
||||
temp['misp_remote_version'] = server_temp['version']
|
||||
if server_temp['status'] == 2:
|
||||
temp['connection_status'] = 'Not Connected'
|
||||
temp['error_status'] = 'Server unrecheable'
|
||||
if server_temp['status'] == 3:
|
||||
temp['connection_status'] = 'Not Connected'
|
||||
temp['error_status'] = 'Unexpected error'
|
||||
if server_temp['status'] == 4:
|
||||
temp['connection_status'] = 'Not Connected'
|
||||
temp['error_status'] = 'Authentication failed'
|
||||
if server_temp['status'] == 8:
|
||||
# Es limitada la conexión, pero tiene acceso a datos
|
||||
if 'status' in server_temp['post']:
|
||||
if server_temp['post']['status'] == 1:
|
||||
temp['connection_status'] = 'Connected'
|
||||
temp['error_status'] = 'Limited connected'
|
||||
if 'info' in server_temp:
|
||||
temp['misp_remote_version'] = server_temp['info']['version']
|
||||
else:
|
||||
temp['connection_status'] = 'Not Connected'
|
||||
temp['error_status'] = 'Unexpected error'
|
||||
else:
|
||||
temp['connection_status'] = 'Not Connected'
|
||||
temp['error_status'] = 'Unexpected error'
|
||||
|
||||
# Se agregan datos a lista de servidores
|
||||
server_list.append(temp)
|
||||
|
||||
# Se reordena para que los no conectados queden al principio del array
|
||||
server_list.sort(key=lambda x: x['connection_status'] != 'Not Connected')
|
||||
|
||||
# Log
|
||||
logging.info("Se guarda datos de conexión de servidor ID:"+str(s.id))
|
||||
except Exception as e:
|
||||
logging.error("Error al obtener servidor :"+str(e))
|
||||
|
||||
return server_list
|
||||
|
||||
def schedule_job(self):
|
||||
try:
|
||||
cron = CronTab(user=True)
|
||||
|
||||
# Comando que deseas agendar
|
||||
command = settings.CRON_CONFIG["command"]
|
||||
|
||||
# Elimina cualquier trabajo previo con el mismo comando
|
||||
cron.remove_all(command=command)
|
||||
|
||||
# Crear un nuevo job
|
||||
job = cron.new(command=command)
|
||||
|
||||
# Configurar el job de acuerdo a los intervalos especificados
|
||||
interval = settings.CRON_CONFIG["interval"]
|
||||
|
||||
if interval.get("minutes"):
|
||||
minutes = interval["minutes"]
|
||||
if minutes < 60:
|
||||
job.minute.every(minutes)
|
||||
else:
|
||||
hours = minutes // 60
|
||||
remaining_minutes = minutes % 60
|
||||
job.minute.on(remaining_minutes)
|
||||
job.hour.every(hours)
|
||||
|
||||
if interval.get("hours"):
|
||||
hours = interval["hours"]
|
||||
if hours <= 24:
|
||||
job.minute.on(0)
|
||||
job.hour.every(hours)
|
||||
else:
|
||||
days = hours // 24
|
||||
remaining_hours = hours % 24
|
||||
job.minute.on(0)
|
||||
job.hour.on(remaining_hours)
|
||||
job.day.every(days)
|
||||
|
||||
if interval.get("days"):
|
||||
days = interval["days"]
|
||||
if days <= 30:
|
||||
job.setall(f'0 0 */{days} * *')
|
||||
else:
|
||||
# Para intervalos de más de 30 días, convierte en meses
|
||||
months = days // 30
|
||||
job.setall(f'0 0 1 */{months} *') # Ejecuta cada cierto número de meses el primer día del mes
|
||||
|
||||
if interval.get("months"):
|
||||
job.setall(f'0 0 1 */{interval["months"]} *')
|
||||
|
||||
cron.write()
|
||||
logging.info("Se configura Job exitosamente en crontab. Comando insertado :"+settings.CRON_CONFIG["command"])
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(str(e))
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
9
main.py
Normal file
9
main.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from defs import MISPAlertManager
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
manager = MISPAlertManager()
|
||||
if manager.enviar_alerta():
|
||||
print("Correo enviado correctamente.")
|
||||
else:
|
||||
print("No se pudo enviar el correo. Favor revisar archivo log.")
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
pymisp
|
||||
python-crontab
|
||||
xlsxwriter
|
17
run.sh
Normal file
17
run.sh
Normal file
|
@ -0,0 +1,17 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Activate
|
||||
source /home/user/misp-alertas/venv/bin/activate
|
||||
|
||||
# Enter folder
|
||||
cd /home/user/misp-alertas/
|
||||
|
||||
# Llamar al script Python
|
||||
python main.py
|
||||
|
||||
# Deactivate
|
||||
deactivate
|
||||
|
||||
|
||||
|
||||
|
8
save_job.py
Normal file
8
save_job.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from defs import MISPAlertManager
|
||||
|
||||
manager = MISPAlertManager()
|
||||
|
||||
if manager.schedule_job():
|
||||
print("Tarea programada correctamente en crontab.")
|
||||
else:
|
||||
print("Error al programar la tarea. Favor revisar archivo log.")
|
17
save_job.sh
Normal file
17
save_job.sh
Normal file
|
@ -0,0 +1,17 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Activate
|
||||
source /home/user/misp-alertas/venv/bin/activate
|
||||
|
||||
# Enter folder
|
||||
cd /home/user/misp-alertas/
|
||||
|
||||
# Llamar al script Python
|
||||
python save_job.py
|
||||
|
||||
# Deactivate
|
||||
deactivate
|
||||
|
||||
|
||||
|
||||
|
49
settings.py
Normal file
49
settings.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Workdir
|
||||
import os
|
||||
|
||||
WORK_DIR = os.getcwd()
|
||||
|
||||
# Si desea adjuntar datos como un archivo adjunto en cada alerta
|
||||
DATA_ATTACH = False
|
||||
|
||||
# Solo servidores desconectados
|
||||
SERVERS_OFF = True
|
||||
|
||||
|
||||
# Si no va a ejecutar entorno virtual, puede llamar directo a main.py, sino cambiar por .sh
|
||||
FNAME = 'run.sh'
|
||||
PATH_EXEC = os.path.join(WORK_DIR, FNAME)
|
||||
COMANDO_CRON = "echo 'TEST'"
|
||||
|
||||
if FNAME.endswith(".py"):
|
||||
COMANDO_CRON = "cd "+WORK_DIR+" && python3 "+FNAME
|
||||
elif FNAME.endswith(".sh"):
|
||||
COMANDO_CRON = "bash "+PATH_EXEC
|
||||
|
||||
|
||||
MISP_CONFIG = {
|
||||
'URL_MISP':'<URL MISP>',
|
||||
'AUTHKEY':'<AUTHKEY>'
|
||||
}
|
||||
|
||||
EMAIL_CONFIG = {
|
||||
'server_smtp_host':'<SMTP HOST>',
|
||||
'server_smtp_port': 587,
|
||||
'smtp_username': '<SMTP USERNAME>',
|
||||
'smtp_password': '<SMTP PASSWORD>',
|
||||
'email_recipient':'<SMTP EMAIL TO>',
|
||||
'tls': True, # si utiliza TLS, sino False
|
||||
'email_subject':'<EMAIL SUBJECT>',
|
||||
'output_color_status':'#16b606',# Color HEX alerta conectado
|
||||
'output_color_status_error':'#f71005'# Color HEX alerta
|
||||
}
|
||||
|
||||
CRON_CONFIG = {
|
||||
"interval": {
|
||||
"minutes": None,
|
||||
"hours": 1, # Esto indica "cada hora" (Por defecto)
|
||||
"days": None,
|
||||
"months": None
|
||||
},
|
||||
"command": COMANDO_CRON
|
||||
}
|
Loading…
Add table
Reference in a new issue