from django.db import models
from django.contrib.postgres.fields import ArrayField
from django.core.validators import MinValueValidator, MaxValueValidator
class SoftDeleteManager(models.Manager):
"""软删除管理器:默认只返回未删除的记录"""
def get_queryset(self):
return super().get_queryset().filter(deleted_at__isnull=True)
class Subdomain(models.Model):
"""
子域名模型(纯资产表)
设计特点:
- 只存储子域名资产信息
- 与其他资产表(IPAddress、Port)无直接关联
- 扫描历史记录存储在 SubdomainSnapshot 快照表中
"""
id = models.AutoField(primary_key=True)
target = models.ForeignKey(
'targets.Target', # 使用字符串引用避免循环导入
on_delete=models.CASCADE,
related_name='subdomains',
help_text='所属的扫描目标(主关联字段,表示所属关系,不能为空)'
)
name = models.CharField(max_length=1000, help_text='子域名名称')
discovered_at = models.DateTimeField(auto_now_add=True, help_text='首次发现时间')
# ==================== 软删除字段 ====================
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text='删除时间(NULL表示未删除)')
# ==================== 管理器 ====================
objects = SoftDeleteManager() # 默认管理器:只返回未删除的记录
all_objects = models.Manager() # 全量管理器:包括已删除的记录(用于硬删除)
class Meta:
db_table = 'subdomain'
verbose_name = '子域名'
verbose_name_plural = '子域名'
ordering = ['-discovered_at']
indexes = [
models.Index(fields=['-discovered_at']),
models.Index(fields=['name', 'target']), # 复合索引,优化 get_by_names_and_target_id 批量查询
models.Index(fields=['target']), # 优化从target_id快速查找下面的子域名
models.Index(fields=['name']), # 优化从name快速查找子域名,搜索场景
models.Index(fields=['deleted_at', '-discovered_at']), # 软删除 + 时间索引
]
constraints = [
# 部分唯一约束:只对未删除记录生效
models.UniqueConstraint(
fields=['name', 'target'],
condition=models.Q(deleted_at__isnull=True),
name='unique_name_target_active'
)
]
def __str__(self):
return str(self.name or f'Subdomain {self.id}')
class Endpoint(models.Model):
"""端点模型"""
id = models.AutoField(primary_key=True)
target = models.ForeignKey(
'targets.Target', # 使用字符串引用
on_delete=models.CASCADE,
related_name='endpoints',
help_text='所属的扫描目标(主关联字段,表示所属关系,不能为空)'
)
url = models.CharField(max_length=2000, help_text='最终访问的完整URL')
host = models.CharField(
max_length=253,
blank=True,
default='',
help_text='主机名(域名或IP地址)'
)
location = models.CharField(
max_length=1000,
blank=True,
default='',
help_text='重定向地址(HTTP 3xx 响应头 Location)'
)
discovered_at = models.DateTimeField(auto_now_add=True, help_text='发现时间')
title = models.CharField(
max_length=1000,
blank=True,
default='',
help_text='网页标题(HTML
标签内容)'
)
webserver = models.CharField(
max_length=200,
blank=True,
default='',
help_text='服务器类型(HTTP 响应头 Server 值)'
)
body_preview = models.CharField(
max_length=1000,
blank=True,
default='',
help_text='响应正文前N个字符(默认100个字符)'
)
content_type = models.CharField(
max_length=200,
blank=True,
default='',
help_text='响应类型(HTTP Content-Type 响应头)'
)
tech = ArrayField(
models.CharField(max_length=100),
blank=True,
default=list,
help_text='技术栈(服务器/框架/语言等)'
)
status_code = models.IntegerField(
null=True,
blank=True,
help_text='HTTP状态码'
)
content_length = models.IntegerField(
null=True,
blank=True,
help_text='响应体大小(单位字节)'
)
vhost = models.BooleanField(
null=True,
blank=True,
help_text='是否支持虚拟主机'
)
matched_gf_patterns = ArrayField(
models.CharField(max_length=100),
blank=True,
default=list,
help_text='匹配的GF模式列表,用于识别敏感端点(如api, debug, config等)'
)
# ==================== 软删除字段 ====================
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text='删除时间(NULL表示未删除)')
# ==================== 管理器 ====================
objects = SoftDeleteManager() # 默认管理器:只返回未删除的记录
all_objects = models.Manager() # 全量管理器:包括已删除的记录(用于硬删除)
class Meta:
db_table = 'endpoint'
verbose_name = '端点'
verbose_name_plural = '端点'
ordering = ['-discovered_at']
indexes = [
models.Index(fields=['-discovered_at']),
models.Index(fields=['target']), # 优化从target_id快速查找下面的端点(主关联字段)
models.Index(fields=['url']), # URL索引,优化查询性能
models.Index(fields=['host']), # host索引,优化根据主机名查询
models.Index(fields=['status_code']), # 状态码索引,优化筛选
models.Index(fields=['deleted_at', '-discovered_at']), # 软删除 + 时间索引
]
constraints = [
# 部分唯一约束:只对未删除记录生效
models.UniqueConstraint(
fields=['url', 'target'],
condition=models.Q(deleted_at__isnull=True),
name='unique_endpoint_url_target_active'
)
]
def __str__(self):
return str(self.url or f'Endpoint {self.id}')
class WebSite(models.Model):
"""站点模型"""
id = models.AutoField(primary_key=True)
target = models.ForeignKey(
'targets.Target', # 使用字符串引用
on_delete=models.CASCADE,
related_name='websites',
help_text='所属的扫描目标(主关联字段,表示所属关系,不能为空)'
)
url = models.CharField(max_length=2000, help_text='最终访问的完整URL')
host = models.CharField(
max_length=253,
blank=True,
default='',
help_text='主机名(域名或IP地址)'
)
location = models.CharField(
max_length=1000,
blank=True,
default='',
help_text='重定向地址(HTTP 3xx 响应头 Location)'
)
discovered_at = models.DateTimeField(auto_now_add=True, help_text='发现时间')
title = models.CharField(
max_length=1000,
blank=True,
default='',
help_text='网页标题(HTML 标签内容)'
)
webserver = models.CharField(
max_length=200,
blank=True,
default='',
help_text='服务器类型(HTTP 响应头 Server 值)'
)
body_preview = models.CharField(
max_length=1000,
blank=True,
default='',
help_text='响应正文前N个字符(默认100个字符)'
)
content_type = models.CharField(
max_length=200,
blank=True,
default='',
help_text='响应类型(HTTP Content-Type 响应头)'
)
tech = ArrayField(
models.CharField(max_length=100),
blank=True,
default=list,
help_text='技术栈(服务器/框架/语言等)'
)
status_code = models.IntegerField(
null=True,
blank=True,
help_text='HTTP状态码'
)
content_length = models.IntegerField(
null=True,
blank=True,
help_text='响应体大小(单位字节)'
)
vhost = models.BooleanField(
null=True,
blank=True,
help_text='是否支持虚拟主机'
)
# ==================== 软删除字段 ====================
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text='删除时间(NULL表示未删除)')
# ==================== 管理器 ====================
objects = SoftDeleteManager() # 默认管理器:只返回未删除的记录
all_objects = models.Manager() # 全量管理器:包括已删除的记录(用于硬删除)
class Meta:
db_table = 'website'
verbose_name = '站点'
verbose_name_plural = '站点'
ordering = ['-discovered_at']
indexes = [
models.Index(fields=['-discovered_at']),
models.Index(fields=['url']), # URL索引,优化查询性能
models.Index(fields=['host']), # host索引,优化根据主机名查询
models.Index(fields=['target']), # 优化从target_id快速查找下面的站点
models.Index(fields=['deleted_at', '-discovered_at']), # 软删除 + 时间索引
]
constraints = [
# 部分唯一约束:只对未删除记录生效
models.UniqueConstraint(
fields=['url', 'target'],
condition=models.Q(deleted_at__isnull=True),
name='unique_website_url_target_active'
)
]
def __str__(self):
return str(self.url or f'Website {self.id}')
class Directory(models.Model):
"""
目录模型
"""
id = models.AutoField(primary_key=True)
website = models.ForeignKey(
'Website',
on_delete=models.CASCADE,
related_name='directories',
help_text='所属的站点(主关联字段,表示所属关系,不能为空)'
)
target = models.ForeignKey(
'targets.Target', # 使用字符串引用
on_delete=models.CASCADE,
related_name='directories',
null=True,
blank=True,
help_text='所属的扫描目标(冗余字段,用于快速查询)'
)
url = models.CharField(
null=False,
blank=False,
max_length=2000,
help_text='完整请求 URL'
)
status = models.IntegerField(
null=True,
blank=True,
help_text='HTTP 响应状态码'
)
content_length = models.BigIntegerField(
null=True,
blank=True,
help_text='响应体字节大小(Content-Length 或实际长度)'
)
words = models.IntegerField(
null=True,
blank=True,
help_text='响应体中单词数量(按空格分割)'
)
lines = models.IntegerField(
null=True,
blank=True,
help_text='响应体行数(按换行符分割)'
)
content_type = models.CharField(
max_length=200,
blank=True,
default='',
help_text='响应头 Content-Type 值'
)
duration = models.BigIntegerField(
null=True,
blank=True,
help_text='请求耗时(单位:纳秒)'
)
discovered_at = models.DateTimeField(auto_now_add=True, help_text='发现时间')
# ==================== 软删除字段 ====================
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text='删除时间(NULL表示未删除)')
# ==================== 管理器 ====================
objects = SoftDeleteManager() # 默认管理器:只返回未删除的记录
all_objects = models.Manager() # 全量管理器:包括已删除的记录(用于硬删除)
class Meta:
db_table = 'directory'
verbose_name = '目录'
verbose_name_plural = '目录'
ordering = ['-discovered_at']
indexes = [
models.Index(fields=['-discovered_at']),
models.Index(fields=['target']), # 优化从target_id快速查找下面的目录
models.Index(fields=['url']), # URL索引,优化搜索和唯一约束
models.Index(fields=['website']), # 站点索引,优化按站点查询
models.Index(fields=['status']), # 状态码索引,优化筛选
models.Index(fields=['deleted_at', '-discovered_at']), # 软删除 + 时间索引
]
constraints = [
# 部分唯一约束:只对未删除记录生效
models.UniqueConstraint(
fields=['website', 'url'],
condition=models.Q(deleted_at__isnull=True),
name='unique_directory_url_website_active'
),
]
def __str__(self):
return str(self.url or f'Directory {self.id}')
class HostPortMapping(models.Model):
"""
主机端口映射表
设计特点:
- 存储主机(host)、IP、端口的三元映射关系
- 只关联 target_id,不关联其他资产表
- target + host + ip + port 组成复合唯一约束
"""
id = models.AutoField(primary_key=True)
# ==================== 关联字段 ====================
target = models.ForeignKey(
'targets.Target',
on_delete=models.CASCADE,
related_name='host_port_mappings',
help_text='所属的扫描目标'
)
# ==================== 核心字段 ====================
host = models.CharField(
max_length=1000,
blank=False,
help_text='主机名(域名或IP)'
)
ip = models.GenericIPAddressField(
blank=False,
help_text='IP地址'
)
port = models.IntegerField(
blank=False,
validators=[
MinValueValidator(1, message='端口号必须大于等于1'),
MaxValueValidator(65535, message='端口号必须小于等于65535')
],
help_text='端口号(1-65535)'
)
# ==================== 时间字段 ====================
discovered_at = models.DateTimeField(
auto_now_add=True,
help_text='发现时间'
)
# ==================== 软删除字段 ====================
deleted_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
help_text='删除时间(NULL表示未删除)'
)
# ==================== 管理器 ====================
objects = SoftDeleteManager() # 默认管理器:只返回未删除的记录
all_objects = models.Manager() # 全量管理器:包括已删除的记录(用于硬删除)
class Meta:
db_table = 'host_port_mapping'
verbose_name = '主机端口映射'
verbose_name_plural = '主机端口映射'
ordering = ['-discovered_at']
indexes = [
models.Index(fields=['target']), # 优化按目标查询
models.Index(fields=['host']), # 优化按主机名查询
models.Index(fields=['ip']), # 优化按IP查询
models.Index(fields=['port']), # 优化按端口查询
models.Index(fields=['host', 'ip']), # 优化组合查询
models.Index(fields=['-discovered_at']), # 优化时间排序
models.Index(fields=['deleted_at', '-discovered_at']), # 软删除 + 时间索引
]
constraints = [
# 复合唯一约束:target + host + ip + port 组合唯一(只对未删除记录生效)
models.UniqueConstraint(
fields=['target', 'host', 'ip', 'port'],
condition=models.Q(deleted_at__isnull=True),
name='unique_target_host_ip_port_active'
),
]
def __str__(self):
return f'{self.host} ({self.ip}:{self.port})'
class Vulnerability(models.Model):
"""
漏洞模型(资产表)
存储发现的漏洞资产,与 Target 关联。
扫描历史记录存储在 VulnerabilitySnapshot 快照表中。
"""
# 延迟导入避免循环引用
from apps.common.definitions import VulnSeverity
id = models.AutoField(primary_key=True)
target = models.ForeignKey(
'targets.Target',
on_delete=models.CASCADE,
related_name='vulnerabilities',
help_text='所属的扫描目标'
)
# ==================== 核心字段 ====================
url = models.TextField(help_text='漏洞所在的URL')
vuln_type = models.CharField(max_length=100, help_text='漏洞类型(如 xss, sqli)')
severity = models.CharField(
max_length=20,
choices=VulnSeverity.choices,
default=VulnSeverity.UNKNOWN,
help_text='严重性(未知/信息/低/中/高/危急)'
)
source = models.CharField(max_length=50, blank=True, default='', help_text='来源工具(如 dalfox, nuclei, crlfuzz)')
cvss_score = models.DecimalField(max_digits=3, decimal_places=1, null=True, blank=True, help_text='CVSS 评分(0.0-10.0)')
description = models.TextField(blank=True, default='', help_text='漏洞描述')
raw_output = models.JSONField(blank=True, default=dict, help_text='工具原始输出')
# ==================== 时间字段 ====================
discovered_at = models.DateTimeField(auto_now_add=True, help_text='首次发现时间')
# ==================== 软删除字段 ====================
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text='删除时间(NULL表示未删除)')
# ==================== 管理器 ====================
objects = SoftDeleteManager()
all_objects = models.Manager()
class Meta:
db_table = 'vulnerability'
verbose_name = '漏洞'
verbose_name_plural = '漏洞'
ordering = ['-discovered_at']
indexes = [
models.Index(fields=['target']),
models.Index(fields=['vuln_type']),
models.Index(fields=['severity']),
models.Index(fields=['source']),
models.Index(fields=['-discovered_at']),
models.Index(fields=['deleted_at', '-discovered_at']),
]
def __str__(self):
return f'{self.vuln_type} - {self.url[:50]}'