feat: project scaffold, unified Pydantic schema, scope gating
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.venv
|
||||
out
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
scope.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Runtime output (operator data — never committed)
|
||||
/out/
|
||||
report.json
|
||||
report.md
|
||||
|
||||
# Local scope file may contain real target lists — keep the example only.
|
||||
/scope.yaml
|
||||
@@ -0,0 +1,48 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "recon-triage"
|
||||
version = "0.1.0"
|
||||
description = "Defensive bug-bounty recon & triage pipeline (authorized testing only)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "recon-triage" }]
|
||||
dependencies = [
|
||||
"pydantic>=2.6,<3",
|
||||
"typer>=0.12,<1",
|
||||
"pyyaml>=6.0",
|
||||
"xmltodict>=0.13",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.4",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
recon-triage = "recon_triage.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W", "UP", "B"]
|
||||
ignore = ["E501", "UP042"] # UP042: `str, Enum` is intentional for broad compatibility
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# typer.Option() in argument defaults is the canonical Typer idiom.
|
||||
"src/recon_triage/cli.py" = ["B008"]
|
||||
@@ -0,0 +1,709 @@
|
||||
{
|
||||
"$defs": {
|
||||
"ExploitDBMatch": {
|
||||
"additionalProperties": false,
|
||||
"description": "An Exploit-DB candidate returned verbatim by ``searchsploit``.\n\nThese are ALWAYS unverified references. We never fabricate or infer EDB-IDs or\npaths; only what searchsploit actually returned is emitted.",
|
||||
"properties": {
|
||||
"edb_id": {
|
||||
"description": "Exploit-DB id, e.g. '50383'.",
|
||||
"title": "Edb Id",
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"title": "Title",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "Local path within the Exploit-DB checkout.",
|
||||
"title": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "exploit-db.com URL.",
|
||||
"title": "Url"
|
||||
},
|
||||
"query": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "The searchsploit query that produced this hit.",
|
||||
"title": "Query"
|
||||
},
|
||||
"source": {
|
||||
"default": "exploit-db",
|
||||
"title": "Source",
|
||||
"type": "string"
|
||||
},
|
||||
"verified": {
|
||||
"default": false,
|
||||
"description": "Always false: candidate reference only.",
|
||||
"title": "Verified",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"edb_id",
|
||||
"title",
|
||||
"path"
|
||||
],
|
||||
"title": "ExploitDBMatch",
|
||||
"type": "object"
|
||||
},
|
||||
"Host": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"hostname": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Hostname"
|
||||
},
|
||||
"ips": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Ips",
|
||||
"type": "array"
|
||||
},
|
||||
"cnames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Cnames",
|
||||
"type": "array"
|
||||
},
|
||||
"ports": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/Port"
|
||||
},
|
||||
"title": "Ports",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"title": "Host",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpInfo": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Url"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Status"
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Title"
|
||||
},
|
||||
"webserver": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Webserver"
|
||||
},
|
||||
"technologies": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Technologies",
|
||||
"type": "array"
|
||||
},
|
||||
"tls": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/TlsInfo"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"title": "HttpInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"NucleiFinding": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"template_id": {
|
||||
"title": "Template Id",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Name"
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/$defs/Severity",
|
||||
"default": "unknown"
|
||||
},
|
||||
"matched_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Matched At"
|
||||
},
|
||||
"host": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Host"
|
||||
},
|
||||
"reference": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Reference",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"template_id"
|
||||
],
|
||||
"title": "NucleiFinding",
|
||||
"type": "object"
|
||||
},
|
||||
"Port": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"number": {
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"title": "Number",
|
||||
"type": "integer"
|
||||
},
|
||||
"protocol": {
|
||||
"default": "tcp",
|
||||
"title": "Protocol",
|
||||
"type": "string"
|
||||
},
|
||||
"state": {
|
||||
"default": "open",
|
||||
"title": "State",
|
||||
"type": "string"
|
||||
},
|
||||
"service": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/Service"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"number"
|
||||
],
|
||||
"title": "Port",
|
||||
"type": "object"
|
||||
},
|
||||
"ScopeUsed": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"in_scope_domains": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "In Scope Domains",
|
||||
"type": "array"
|
||||
},
|
||||
"in_scope_cidrs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "In Scope Cidrs",
|
||||
"type": "array"
|
||||
},
|
||||
"out_of_scope": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Out Of Scope",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"title": "ScopeUsed",
|
||||
"type": "object"
|
||||
},
|
||||
"Service": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Service name, e.g. 'http'.",
|
||||
"title": "Name"
|
||||
},
|
||||
"product": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Product, e.g. 'Apache httpd'.",
|
||||
"title": "Product"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Version string, e.g. '2.4.49'.",
|
||||
"title": "Version"
|
||||
},
|
||||
"cpe": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Cpe"
|
||||
},
|
||||
"http": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HttpInfo"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"exploitdb_candidates": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/ExploitDBMatch"
|
||||
},
|
||||
"title": "Exploitdb Candidates",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"title": "Service",
|
||||
"type": "object"
|
||||
},
|
||||
"Severity": {
|
||||
"enum": [
|
||||
"info",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"critical",
|
||||
"unknown"
|
||||
],
|
||||
"title": "Severity",
|
||||
"type": "string"
|
||||
},
|
||||
"StageResult": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/$defs/StageStatus",
|
||||
"default": "skipped"
|
||||
},
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Detail"
|
||||
},
|
||||
"started_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Started At"
|
||||
},
|
||||
"finished_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Finished At"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"title": "StageResult",
|
||||
"type": "object"
|
||||
},
|
||||
"StageStatus": {
|
||||
"description": "Per-tool stage outcome. The pipeline fails soft: a tool that crashes, times\nout, or returns nothing is recorded here and the run continues.",
|
||||
"enum": [
|
||||
"ok",
|
||||
"empty",
|
||||
"failed",
|
||||
"skipped"
|
||||
],
|
||||
"title": "StageStatus",
|
||||
"type": "string"
|
||||
},
|
||||
"TlsInfo": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"subject_cn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Subject Cn"
|
||||
},
|
||||
"issuer": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Issuer"
|
||||
},
|
||||
"not_after": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Not After"
|
||||
}
|
||||
},
|
||||
"title": "TlsInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"ToolVersion": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Version"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"title": "ToolVersion",
|
||||
"type": "object"
|
||||
},
|
||||
"TriageItem": {
|
||||
"additionalProperties": false,
|
||||
"description": "One prioritized finding from the LLM stage. All references must point at ids\npresent in the ReconReport \u2014 see triage.llm for the acceptance gate.",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"title": "Summary",
|
||||
"type": "string"
|
||||
},
|
||||
"rationale": {
|
||||
"title": "Rationale",
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/$defs/Severity",
|
||||
"default": "unknown"
|
||||
},
|
||||
"evidence_refs": {
|
||||
"description": "ids/keys present in the ReconReport (host:port, edb_id, template_id).",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Evidence Refs",
|
||||
"type": "array"
|
||||
},
|
||||
"suggested_next_step": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Must reference a real edb_id / nuclei template / host:port from input.",
|
||||
"title": "Suggested Next Step"
|
||||
},
|
||||
"confidence": {
|
||||
"default": 0.0,
|
||||
"maximum": 1.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Confidence",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary",
|
||||
"rationale"
|
||||
],
|
||||
"title": "TriageItem",
|
||||
"type": "object"
|
||||
},
|
||||
"TriageReport": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"prioritized_findings": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/TriageItem"
|
||||
},
|
||||
"title": "Prioritized Findings",
|
||||
"type": "array"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Model"
|
||||
},
|
||||
"generated_by": {
|
||||
"default": "deterministic",
|
||||
"description": "'llm' or 'deterministic'.",
|
||||
"title": "Generated By",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "TriageReport",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Top-level run artifact. Schema-valid instances are written to report.json.",
|
||||
"properties": {
|
||||
"run_id": {
|
||||
"title": "Run Id",
|
||||
"type": "string"
|
||||
},
|
||||
"target": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Target"
|
||||
},
|
||||
"started_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Started At"
|
||||
},
|
||||
"finished_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Finished At"
|
||||
},
|
||||
"tool_versions": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/ToolVersion"
|
||||
},
|
||||
"title": "Tool Versions",
|
||||
"type": "array"
|
||||
},
|
||||
"scope": {
|
||||
"$ref": "#/$defs/ScopeUsed"
|
||||
},
|
||||
"stages": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/StageResult"
|
||||
},
|
||||
"title": "Stages",
|
||||
"type": "array"
|
||||
},
|
||||
"hosts": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/Host"
|
||||
},
|
||||
"title": "Hosts",
|
||||
"type": "array"
|
||||
},
|
||||
"nuclei_findings": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/NucleiFinding"
|
||||
},
|
||||
"title": "Nuclei Findings",
|
||||
"type": "array"
|
||||
},
|
||||
"triage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/TriageReport"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"notice": {
|
||||
"default": "Authorized use only. Recon-and-triage scope: this report enumerates and references findings; it does not run or generate exploits.",
|
||||
"title": "Notice",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"run_id"
|
||||
],
|
||||
"title": "ReconReport",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$defs": {
|
||||
"Severity": {
|
||||
"enum": [
|
||||
"info",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"critical",
|
||||
"unknown"
|
||||
],
|
||||
"title": "Severity",
|
||||
"type": "string"
|
||||
},
|
||||
"TriageItem": {
|
||||
"additionalProperties": false,
|
||||
"description": "One prioritized finding from the LLM stage. All references must point at ids\npresent in the ReconReport \u2014 see triage.llm for the acceptance gate.",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"title": "Summary",
|
||||
"type": "string"
|
||||
},
|
||||
"rationale": {
|
||||
"title": "Rationale",
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/$defs/Severity",
|
||||
"default": "unknown"
|
||||
},
|
||||
"evidence_refs": {
|
||||
"description": "ids/keys present in the ReconReport (host:port, edb_id, template_id).",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Evidence Refs",
|
||||
"type": "array"
|
||||
},
|
||||
"suggested_next_step": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Must reference a real edb_id / nuclei template / host:port from input.",
|
||||
"title": "Suggested Next Step"
|
||||
},
|
||||
"confidence": {
|
||||
"default": 0.0,
|
||||
"maximum": 1.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Confidence",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary",
|
||||
"rationale"
|
||||
],
|
||||
"title": "TriageItem",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"prioritized_findings": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/TriageItem"
|
||||
},
|
||||
"title": "Prioritized Findings",
|
||||
"type": "array"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Model"
|
||||
},
|
||||
"generated_by": {
|
||||
"default": "deterministic",
|
||||
"description": "'llm' or 'deterministic'.",
|
||||
"title": "Generated By",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "TriageReport",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# recon-triage scope file — AUTHORIZED TARGETS ONLY.
|
||||
#
|
||||
# Only assets listed here are touched. Anything resolving outside these rules is
|
||||
# dropped with a logged warning and never scanned. out_of_scope takes precedence.
|
||||
#
|
||||
# Domain rules match subdomains: "example.com" also covers "api.example.com".
|
||||
|
||||
in_scope_domains:
|
||||
- example.com
|
||||
|
||||
in_scope_cidrs:
|
||||
- 93.184.216.0/24
|
||||
|
||||
out_of_scope:
|
||||
# Exclude specific hosts or sub-ranges even if they fall under an in-scope rule.
|
||||
- internal-only.example.com
|
||||
- 93.184.216.200/30
|
||||
@@ -0,0 +1,9 @@
|
||||
"""recon-triage: defensive recon & triage pipeline for authorized security testing only.
|
||||
|
||||
This package performs reconnaissance and triage ONLY. It never runs, generates,
|
||||
downloads, or executes exploits. It enumerates assets, normalizes tool output into
|
||||
a single schema, grounds findings against Exploit-DB via searchsploit, and emits a
|
||||
prioritized report. A human decides what to do next.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Unified schema for recon-triage — the single source of truth for all output.
|
||||
|
||||
Every tool normalizer maps into this model tree, and the report/LLM stages consume
|
||||
it. The JSON Schema is exported to ``schemas/`` so the contract is inspectable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class StageStatus(str, Enum):
|
||||
"""Per-tool stage outcome. The pipeline fails soft: a tool that crashes, times
|
||||
out, or returns nothing is recorded here and the run continues."""
|
||||
|
||||
OK = "ok"
|
||||
EMPTY = "empty"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
INFO = "info"
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
# Ordering used by the deterministic fallback ranking (higher = more urgent).
|
||||
SEVERITY_RANK: dict[str, int] = {
|
||||
Severity.CRITICAL.value: 5,
|
||||
Severity.HIGH.value: 4,
|
||||
Severity.MEDIUM.value: 3,
|
||||
Severity.LOW.value: 2,
|
||||
Severity.INFO.value: 1,
|
||||
Severity.UNKNOWN.value: 0,
|
||||
}
|
||||
|
||||
|
||||
class _Model(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", use_enum_values=True)
|
||||
|
||||
|
||||
class ExploitDBMatch(_Model):
|
||||
"""An Exploit-DB candidate returned verbatim by ``searchsploit``.
|
||||
|
||||
These are ALWAYS unverified references. We never fabricate or infer EDB-IDs or
|
||||
paths; only what searchsploit actually returned is emitted.
|
||||
"""
|
||||
|
||||
edb_id: str = Field(description="Exploit-DB id, e.g. '50383'.")
|
||||
title: str
|
||||
path: str = Field(description="Local path within the Exploit-DB checkout.")
|
||||
url: str | None = Field(default=None, description="exploit-db.com URL.")
|
||||
query: str | None = Field(default=None, description="The searchsploit query that produced this hit.")
|
||||
source: str = Field(default="exploit-db", frozen=True)
|
||||
verified: bool = Field(default=False, description="Always false: candidate reference only.")
|
||||
|
||||
|
||||
class TlsInfo(_Model):
|
||||
subject_cn: str | None = None
|
||||
issuer: str | None = None
|
||||
not_after: str | None = None
|
||||
|
||||
|
||||
class HttpInfo(_Model):
|
||||
url: str | None = None
|
||||
status: int | None = None
|
||||
title: str | None = None
|
||||
webserver: str | None = None
|
||||
technologies: list[str] = Field(default_factory=list)
|
||||
tls: TlsInfo | None = None
|
||||
|
||||
|
||||
class Service(_Model):
|
||||
name: str | None = Field(default=None, description="Service name, e.g. 'http'.")
|
||||
product: str | None = Field(default=None, description="Product, e.g. 'Apache httpd'.")
|
||||
version: str | None = Field(default=None, description="Version string, e.g. '2.4.49'.")
|
||||
cpe: str | None = None
|
||||
http: HttpInfo | None = None
|
||||
exploitdb_candidates: list[ExploitDBMatch] = Field(default_factory=list)
|
||||
|
||||
def query_term(self) -> str | None:
|
||||
"""Build the searchsploit query: 'product version', falling back to product."""
|
||||
if not self.product:
|
||||
return None
|
||||
if self.version:
|
||||
return f"{self.product} {self.version}".strip()
|
||||
return self.product.strip()
|
||||
|
||||
|
||||
class Port(_Model):
|
||||
number: int = Field(ge=0, le=65535)
|
||||
protocol: str = Field(default="tcp")
|
||||
state: str = Field(default="open")
|
||||
service: Service | None = None
|
||||
|
||||
|
||||
class Host(_Model):
|
||||
hostname: str | None = None
|
||||
ips: list[str] = Field(default_factory=list)
|
||||
cnames: list[str] = Field(default_factory=list)
|
||||
ports: list[Port] = Field(default_factory=list)
|
||||
|
||||
def key(self) -> str:
|
||||
return self.hostname or (self.ips[0] if self.ips else "unknown")
|
||||
|
||||
|
||||
class NucleiFinding(_Model):
|
||||
template_id: str
|
||||
name: str | None = None
|
||||
severity: Severity = Severity.UNKNOWN
|
||||
matched_at: str | None = None
|
||||
host: str | None = None
|
||||
reference: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StageResult(_Model):
|
||||
name: str
|
||||
status: StageStatus = StageStatus.SKIPPED
|
||||
detail: str | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
|
||||
|
||||
class ScopeUsed(_Model):
|
||||
in_scope_domains: list[str] = Field(default_factory=list)
|
||||
in_scope_cidrs: list[str] = Field(default_factory=list)
|
||||
out_of_scope: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ToolVersion(_Model):
|
||||
name: str
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class TriageItem(_Model):
|
||||
"""One prioritized finding from the LLM stage. All references must point at ids
|
||||
present in the ReconReport — see triage.llm for the acceptance gate."""
|
||||
|
||||
summary: str
|
||||
rationale: str
|
||||
severity: Severity = Severity.UNKNOWN
|
||||
evidence_refs: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="ids/keys present in the ReconReport (host:port, edb_id, template_id).",
|
||||
)
|
||||
suggested_next_step: str | None = Field(
|
||||
default=None,
|
||||
description="Must reference a real edb_id / nuclei template / host:port from input.",
|
||||
)
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class TriageReport(_Model):
|
||||
prioritized_findings: list[TriageItem] = Field(default_factory=list)
|
||||
model: str | None = None
|
||||
generated_by: str = Field(default="deterministic", description="'llm' or 'deterministic'.")
|
||||
|
||||
|
||||
class ReconReport(_Model):
|
||||
"""Top-level run artifact. Schema-valid instances are written to report.json."""
|
||||
|
||||
run_id: str
|
||||
target: str | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
tool_versions: list[ToolVersion] = Field(default_factory=list)
|
||||
scope: ScopeUsed = Field(default_factory=ScopeUsed)
|
||||
stages: list[StageResult] = Field(default_factory=list)
|
||||
hosts: list[Host] = Field(default_factory=list)
|
||||
nuclei_findings: list[NucleiFinding] = Field(default_factory=list)
|
||||
triage: TriageReport | None = None
|
||||
notice: str = Field(
|
||||
default="Authorized use only. Recon-and-triage scope: this report enumerates and "
|
||||
"references findings; it does not run or generate exploits.",
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
|
||||
def export_json_schema(out_dir: str | Path) -> Path:
|
||||
"""Write the ReconReport + TriageReport JSON Schemas to ``out_dir``."""
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
recon_path = out / "recon_report.schema.json"
|
||||
triage_path = out / "triage_report.schema.json"
|
||||
recon_path.write_text(json.dumps(ReconReport.model_json_schema(), indent=2) + "\n")
|
||||
triage_path.write_text(json.dumps(TriageReport.model_json_schema(), indent=2) + "\n")
|
||||
return recon_path
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else "schemas"
|
||||
print(export_json_schema(target))
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Scope enforcement — mandatory gate. Anything not explicitly in scope is dropped.
|
||||
|
||||
A ``scope.yaml`` declares in-scope domains and CIDRs plus optional out-of-scope
|
||||
exclusions. The tool refuses to touch anything not in scope: out-of-scope input is
|
||||
dropped with a logged warning, never scanned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .schema import ScopeUsed
|
||||
|
||||
log = logging.getLogger("recon_triage.scope")
|
||||
|
||||
|
||||
def _norm_domain(d: str) -> str:
|
||||
return d.strip().lower().rstrip(".")
|
||||
|
||||
|
||||
def _is_ip(value: str) -> bool:
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scope:
|
||||
"""Loaded scope rules with in/out gating.
|
||||
|
||||
Domain matching includes subdomains (``example.com`` matches ``api.example.com``).
|
||||
Out-of-scope rules take precedence over in-scope rules.
|
||||
"""
|
||||
|
||||
domains: list[str] = field(default_factory=list)
|
||||
cidrs: list[str] = field(default_factory=list)
|
||||
out_of_scope: list[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path) -> Scope:
|
||||
data = yaml.safe_load(Path(path).read_text()) or {}
|
||||
return cls.from_dict(data)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> Scope:
|
||||
domains = [_norm_domain(d) for d in (data.get("in_scope_domains") or []) if d]
|
||||
cidrs = [str(c).strip() for c in (data.get("in_scope_cidrs") or []) if c]
|
||||
out = [str(o).strip().lower().rstrip(".") for o in (data.get("out_of_scope") or []) if o]
|
||||
# Validate CIDRs early; drop invalid with a warning rather than crashing.
|
||||
valid_cidrs = []
|
||||
for c in cidrs:
|
||||
try:
|
||||
ipaddress.ip_network(c, strict=False)
|
||||
valid_cidrs.append(c)
|
||||
except ValueError:
|
||||
log.warning("Ignoring invalid CIDR in scope: %s", c)
|
||||
return cls(domains=domains, cidrs=valid_cidrs, out_of_scope=out)
|
||||
|
||||
# -- matching -----------------------------------------------------------
|
||||
|
||||
def _domain_in(self, host: str, rules: list[str]) -> bool:
|
||||
host = _norm_domain(host)
|
||||
for rule in rules:
|
||||
if host == rule or host.endswith("." + rule):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _ip_in_cidrs(self, ip: str) -> bool:
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
for c in self.cidrs:
|
||||
try:
|
||||
if addr in ipaddress.ip_network(c, strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _ip_out(self, ip: str) -> bool:
|
||||
for rule in self.out_of_scope:
|
||||
if rule == ip:
|
||||
return True
|
||||
# out_of_scope may also carry CIDRs
|
||||
try:
|
||||
if ipaddress.ip_address(ip) in ipaddress.ip_network(rule, strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
def is_in_scope(self, value: str) -> bool:
|
||||
"""True iff ``value`` (a hostname or IP) is in scope and not excluded."""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return False
|
||||
if _is_ip(value):
|
||||
if self._ip_out(value):
|
||||
return False
|
||||
return self._ip_in_cidrs(value)
|
||||
# hostname
|
||||
if self._domain_in(value, self.out_of_scope):
|
||||
return False
|
||||
return self._domain_in(value, self.domains)
|
||||
|
||||
def filter(self, values: list[str]) -> list[str]:
|
||||
"""Return only in-scope values; log a warning for each dropped one."""
|
||||
kept: list[str] = []
|
||||
for v in values:
|
||||
if self.is_in_scope(v):
|
||||
kept.append(v)
|
||||
else:
|
||||
log.warning("Dropping out-of-scope target: %s", v)
|
||||
return kept
|
||||
|
||||
def as_used(self) -> ScopeUsed:
|
||||
return ScopeUsed(
|
||||
in_scope_domains=list(self.domains),
|
||||
in_scope_cidrs=list(self.cidrs),
|
||||
out_of_scope=list(self.out_of_scope),
|
||||
)
|
||||
Reference in New Issue
Block a user