Skip to content

Commit 2dffe77

Browse files
authored
Merge branch 'main' into fix/tls-verification-disabled
2 parents 8f9cf4b + 060d085 commit 2dffe77

29 files changed

Lines changed: 1480 additions & 243 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ jobs:
4141
with:
4242
fetch-depth: 0
4343
- name: Check formatting hygiene on changed files
44-
run: |
45-
git fetch origin "${{ github.base_ref }}" --depth=1
46-
git diff --check "origin/${{ github.base_ref }}"...HEAD
44+
run: git diff --check "${{ github.event.pull_request.base.sha }}"...HEAD
4745

4846
issue-template-label-validation:
4947
runs-on: ubuntu-latest

backend/secuscan/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class Settings(BaseSettings):
3333
raw_output_dir: str = str(PROJECT_ROOT / "data" / "raw")
3434
reports_dir: str = str(PROJECT_ROOT / "data" / "reports")
3535
plugins_dir: str = str(PROJECT_ROOT.parent / "plugins")
36+
disabled_plugins: List[str] = []
3637
wordlists_dir: str = str(PROJECT_ROOT / "wordlists")
3738
knowledgebase_dir: str = str(PROJECT_ROOT / "data" / "knowledgebase")
3839

backend/secuscan/database.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Database:
2323
def __init__(self, db_path: str):
2424
self.db_path = db_path
2525
self._connection = None
26+
self._in_transaction: bool = False
2627

2728
@property
2829
def connection(self) -> aiosqlite.Connection:
@@ -380,7 +381,7 @@ async def _create_schema(self):
380381
CREATE INDEX IF NOT EXISTS idx_findings_owner ON findings(owner_id);
381382
CREATE INDEX IF NOT EXISTS idx_findings_cpe ON findings(cpe);
382383
CREATE INDEX IF NOT EXISTS idx_findings_validated ON findings(validated);
383-
CREATE INDEX IF NOT EXISTS idx_findings_group_id ON findings(owner_id, finding_group_id);
384+
CREATE UNIQUE INDEX IF NOT EXISTS idx_findings_group_id ON findings(owner_id, finding_group_id);
384385
CREATE INDEX IF NOT EXISTS idx_findings_asset_id ON findings(owner_id, asset_id);
385386
386387
-- Reports indexes (new)
@@ -769,19 +770,27 @@ async def transaction(self) -> AsyncIterator["Database"]:
769770
770771
If any statement raises, the entire transaction is rolled back.
771772
On success the transaction is committed automatically.
773+
774+
Nested calls are safe: when a transaction is already active the
775+
inner context manager becomes a no-op so the outer transaction
776+
controls the commit/rollback.
772777
"""
773-
await self.begin()
774-
try:
778+
if self._in_transaction:
775779
yield self
776-
await self.commit()
777-
except Exception:
778-
await self.rollback()
779-
raise
780+
else:
781+
await self.begin()
782+
try:
783+
yield self
784+
await self.commit()
785+
except Exception:
786+
await self.rollback()
787+
raise
780788

781789
async def execute(self, query: str, params: tuple = ()):
782790
"""Execute a write query and return the cursor (so callers can inspect rowcount)."""
783791
cursor = await self.connection.execute(query, params)
784-
await self.connection.commit()
792+
if not self._in_transaction:
793+
await self.connection.commit()
785794
return cursor
786795

787796
async def execute_no_commit(self, query: str, params: tuple = ()):
@@ -790,16 +799,25 @@ async def execute_no_commit(self, query: str, params: tuple = ()):
790799
return cursor
791800

792801
async def begin(self):
793-
"""Begin a transaction."""
802+
"""Begin a transaction. No-op if already in a transaction."""
803+
if self._in_transaction:
804+
return
794805
await self.connection.execute("BEGIN")
806+
self._in_transaction = True
795807

796808
async def commit(self):
797-
"""Commit the current transaction."""
809+
"""Commit the current transaction. No-op if not in a transaction."""
810+
if not self._in_transaction:
811+
return
798812
await self.connection.commit()
813+
self._in_transaction = False
799814

800815
async def rollback(self):
801-
"""Roll back the current transaction."""
816+
"""Roll back the current transaction. No-op if not in a transaction."""
817+
if not self._in_transaction:
818+
return
802819
await self.connection.rollback()
820+
self._in_transaction = False
803821

804822
async def fetchone(self, query: str, params: tuple = ()) -> Optional[Dict]:
805823
"""Fetch one row."""

backend/secuscan/executor.py

Lines changed: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1349,6 +1349,7 @@ async def _persist_finding(
13491349
) -> Dict[str, Any]:
13501350
u_id = str(uuid.uuid4()).replace("-", "")
13511351
finding_id = f"finding:{task_id}:{u_id[:8]}"
1352+
finding_group_id = finding.get("finding_group_id")
13521353

13531354
_validate_risk_fields(finding)
13541355
exploitability = finding.get("exploitability")
@@ -1395,6 +1396,41 @@ async def _persist_finding(
13951396
asset_exposure, risk_score, risk_factors_json
13961397
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
13971398
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1399+
ON CONFLICT (owner_id, finding_group_id) DO UPDATE SET
1400+
task_id = EXCLUDED.task_id,
1401+
plugin_id = EXCLUDED.plugin_id,
1402+
title = EXCLUDED.title,
1403+
category = EXCLUDED.category,
1404+
severity = EXCLUDED.severity,
1405+
target = EXCLUDED.target,
1406+
description = EXCLUDED.description,
1407+
remediation = EXCLUDED.remediation,
1408+
proof = EXCLUDED.proof,
1409+
cvss = EXCLUDED.cvss,
1410+
cve = EXCLUDED.cve,
1411+
metadata_json = EXCLUDED.metadata_json,
1412+
discovered_at = EXCLUDED.discovered_at,
1413+
exploitability = EXCLUDED.exploitability,
1414+
confidence = EXCLUDED.confidence,
1415+
validated = EXCLUDED.validated,
1416+
validation_method = EXCLUDED.validation_method,
1417+
confidence_reason = EXCLUDED.confidence_reason,
1418+
finding_kind = EXCLUDED.finding_kind,
1419+
asset_id = EXCLUDED.asset_id,
1420+
last_seen_at = EXCLUDED.last_seen_at,
1421+
occurrence_count = COALESCE(findings.occurrence_count, 0) + EXCLUDED.occurrence_count,
1422+
corroborating_sources_json = EXCLUDED.corroborating_sources_json,
1423+
evidence_count = EXCLUDED.evidence_count,
1424+
analyst_status = EXCLUDED.analyst_status,
1425+
retest_status = EXCLUDED.retest_status,
1426+
evidence_json = EXCLUDED.evidence_json,
1427+
asset_refs_json = EXCLUDED.asset_refs_json,
1428+
service_fingerprint = EXCLUDED.service_fingerprint,
1429+
cpe = EXCLUDED.cpe,
1430+
references_json = EXCLUDED.references_json,
1431+
asset_exposure = EXCLUDED.asset_exposure,
1432+
risk_score = EXCLUDED.risk_score,
1433+
risk_factors_json = EXCLUDED.risk_factors_json
13981434
""",
13991435
(
14001436
finding_id,
@@ -1418,7 +1454,7 @@ async def _persist_finding(
14181454
finding.get("validation_method"),
14191455
finding.get("confidence_reason"),
14201456
str(finding.get("finding_kind") or "observation"),
1421-
finding.get("finding_group_id"),
1457+
finding_group_id,
14221458
finding.get("asset_id"),
14231459
first_seen_at,
14241460
last_seen_at,
@@ -1437,6 +1473,13 @@ async def _persist_finding(
14371473
json.dumps(risk_factors),
14381474
),
14391475
)
1476+
1477+
row = await db.fetchone(
1478+
"SELECT id, occurrence_count FROM findings WHERE owner_id = ? AND finding_group_id = ?",
1479+
(owner_id, finding_group_id),
1480+
)
1481+
finding_id = row["id"] if row else finding_id
1482+
occurrence_count = int(row["occurrence_count"]) if row else occurrence_count
14401483
return {
14411484
**finding,
14421485
"id": finding_id,
@@ -1468,25 +1511,25 @@ async def _upsert_findings_and_report(self, db, task_id: str, owner_id: str, plu
14681511
result=parsed,
14691512
)
14701513
findings_data: List[Dict[str, Any]] = []
1471-
for finding in structured_result.get("findings", []):
1472-
findings_data.append(
1473-
await self._persist_finding(
1474-
db,
1475-
owner_id=owner_id,
1476-
task_id=task_id,
1477-
plugin_id=plugin_id,
1478-
target=target,
1479-
finding=finding,
1514+
async with db.transaction():
1515+
for finding in structured_result.get("findings", []):
1516+
findings_data.append(
1517+
await self._persist_finding(
1518+
db,
1519+
owner_id=owner_id,
1520+
task_id=task_id,
1521+
plugin_id=plugin_id,
1522+
target=target,
1523+
finding=finding,
1524+
)
14801525
)
1481-
)
14821526

1483-
structured_result["findings"] = findings_data
1484-
structured_result["severity_counts"] = self._build_severity_counts(findings_data)
1485-
structured_result["finding_groups"] = build_finding_groups(findings_data)
1486-
structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services)
1487-
structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings)
1527+
structured_result["findings"] = findings_data
1528+
structured_result["severity_counts"] = self._build_severity_counts(findings_data)
1529+
structured_result["finding_groups"] = build_finding_groups(findings_data)
1530+
structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services)
1531+
structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings)
14881532

1489-
async with db.transaction():
14901533
await db.execute(
14911534
"UPDATE tasks SET structured_json = ? WHERE id = ?",
14921535
(json.dumps(structured_result), task_id)
@@ -1534,25 +1577,25 @@ async def _upsert_findings_and_report_from_scanner(self, db, task_id: str, owner
15341577
result=result,
15351578
)
15361579
findings_data: List[Dict[str, Any]] = []
1537-
for finding in structured_result.get("findings", []):
1538-
findings_data.append(
1539-
await self._persist_finding(
1540-
db,
1541-
owner_id=owner_id,
1542-
task_id=task_id,
1543-
plugin_id=plugin_id,
1544-
target=target,
1545-
finding=finding,
1580+
async with db.transaction():
1581+
for finding in structured_result.get("findings", []):
1582+
findings_data.append(
1583+
await self._persist_finding(
1584+
db,
1585+
owner_id=owner_id,
1586+
task_id=task_id,
1587+
plugin_id=plugin_id,
1588+
target=target,
1589+
finding=finding,
1590+
)
15461591
)
1547-
)
15481592

1549-
structured_result["findings"] = findings_data
1550-
structured_result["severity_counts"] = self._build_severity_counts(findings_data)
1551-
structured_result["finding_groups"] = build_finding_groups(findings_data)
1552-
structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services)
1553-
structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings)
1593+
structured_result["findings"] = findings_data
1594+
structured_result["severity_counts"] = self._build_severity_counts(findings_data)
1595+
structured_result["finding_groups"] = build_finding_groups(findings_data)
1596+
structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services)
1597+
structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings)
15541598

1555-
async with db.transaction():
15561599
await db.execute(
15571600
"UPDATE tasks SET structured_json = ? WHERE id = ?",
15581601
(json.dumps(structured_result), task_id)

backend/secuscan/finding_intelligence.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,19 @@ def _now_iso() -> str:
4242
return datetime.now(timezone.utc).isoformat()
4343

4444

45+
def generate_finding_key(finding: Dict[str, Any], plugin_id: str, target: str, owner_id: str) -> str:
46+
"""
47+
Generate a stable deduplication key for a finding that is consistent
48+
across different scan tasks targeting the same asset. Unlike the per-task
49+
finding ID, this key intentionally excludes any task identifier so that
50+
the same vulnerability discovered by separate tasks produces the same key.
51+
"""
52+
asset_ref = _guess_asset_ref(finding, target)
53+
asset_id = _stable_id("asset", target, asset_ref)
54+
signature = _issue_signature(finding)
55+
return _stable_id("group", plugin_id, asset_id, signature, owner_id)
56+
57+
4558
def _parse_timestamp(raw: Any) -> str:
4659
if isinstance(raw, datetime):
4760
return raw.astimezone(timezone.utc).isoformat()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Migration: 008_add_finding_group_unique
2+
-- Replaces the non-unique index idx_findings_group_id with a UNIQUE
3+
-- index on (owner_id, finding_group_id) so that the same vulnerability
4+
-- found by different tasks does not produce duplicate rows.
5+
6+
BEGIN TRANSACTION;
7+
8+
-- Remove duplicates keeping the most recently discovered row per group
9+
DELETE FROM findings WHERE rowid NOT IN (
10+
SELECT MIN(rowid) FROM findings
11+
WHERE owner_id IS NOT NULL AND finding_group_id IS NOT NULL
12+
GROUP BY owner_id, finding_group_id
13+
);
14+
15+
DROP INDEX IF EXISTS idx_findings_group_id;
16+
CREATE UNIQUE INDEX IF NOT EXISTS idx_findings_group_id ON findings(owner_id, finding_group_id);
17+
18+
COMMIT;

backend/secuscan/parser_sandbox.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def __init__(self, plugin_id: str, reason: str, stderr: str = "") -> None:
7979
self.reason = reason
8080
# Keep stderr private; callers must not surface this to API consumers.
8181
self._stderr_diagnostic: str = stderr
82+
self.stderr_excerpt = stderr[:2000] if stderr else ""
8283
# User-facing message: reason only — no stderr content.
8384
super().__init__(f"Parser sandbox failed for '{plugin_id}' ({reason})")
8485

@@ -262,7 +263,7 @@ def _read_stderr() -> None:
262263
plugin_id,
263264
_sanitize_stderr(stderr_text),
264265
)
265-
raise ParserSandboxError(plugin_id, f"timed out after {timeout_seconds}s")
266+
raise ParserSandboxError(plugin_id, f"timed out after {timeout_seconds}s", stderr_text)
266267

267268
if proc.returncode != 0:
268269
logger.error(
@@ -280,6 +281,7 @@ def _read_stderr() -> None:
280281
raise ParserSandboxError(
281282
plugin_id,
282283
f"subprocess exited with code {proc.returncode}",
284+
stderr_text,
283285
)
284286

285287
stdout_bytes = b"".join(stdout_chunks)
@@ -302,6 +304,7 @@ def _read_stderr() -> None:
302304
raise ParserSandboxError(
303305
plugin_id,
304306
f"parser returned non-JSON output: {exc}",
307+
stderr_text,
305308
)
306309

307310
if not isinstance(parsed, (dict, list)):

0 commit comments

Comments
 (0)