Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions backend/app/analysis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ def run_checks(db: Session, device_id: int) -> list[Risk]:
logger.warning("run_checks: device %d not found", device_id)
return []

# Trusted devices are acknowledged — clear any existing risks and skip checks
existing_stmt = select(Risk).where(Risk.device_id == device_id)
existing = db.execute(existing_stmt).scalars().all()
if device.trusted:
for risk in existing:
db.delete(risk)
logger.debug("Device %d is trusted — skipping checks and clearing risks", device_id)
return []

# Collect findings from all checks
all_findings: list[RiskData] = []
for check_fn in ALL_CHECKS:
Expand All @@ -55,19 +64,9 @@ def run_checks(db: Session, device_id: int) -> list[Risk]:
)

# Build a set of check_ids that fired so we can delete stale entries
fired_ids = {rd.check_id for rd in all_findings}

# Delete existing Risk rows for this device that are about to be replaced
existing_stmt = select(Risk).where(Risk.device_id == device_id)
existing = db.execute(existing_stmt).scalars().all()
for risk in existing:
if risk.check_id in fired_ids:
db.delete(risk)

# Also delete resolved risks (check fired before but not now)
# Delete existing Risk rows for this device (replace fired ones, remove resolved ones)
for risk in existing:
if risk.check_id not in fired_ids:
db.delete(risk)
db.delete(risk)

db.flush()

Expand Down
27 changes: 27 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class DeviceOut(BaseModel):
vendor: str | None
hostname: str | None
os_guess: str | None
trusted: bool
first_seen: str | None # ISO-8601 string
last_seen: str | None
ports: list[PortOut] = []
Expand All @@ -48,6 +49,7 @@ class ScanOut(BaseModel):
finished_at: str | None
duration_seconds: float | None
devices_found: int | None
current_stage: str | None
error_message: str | None
warning_message: str | None
risks_critical: int | None
Expand Down Expand Up @@ -88,6 +90,29 @@ def get_device(device_id: int, db: Annotated[Session, Depends(get_db)]) -> Devic
return _device_to_out(device)


class _TrustedUpdate(BaseModel):
trusted: bool


@router.patch("/devices/{device_id}/trusted", response_model=DeviceOut)
def set_device_trusted(
device_id: int,
body: _TrustedUpdate,
db: Annotated[Session, Depends(get_db)],
) -> DeviceOut:
"""Toggle the trusted flag on a device."""
from app.models.device import Device

stmt = select(Device).options(selectinload(Device.ports)).where(Device.id == device_id)
device = db.execute(stmt).scalar_one_or_none()
if device is None:
raise HTTPException(status_code=404, detail="Device not found")
device.trusted = body.trusted
db.commit()
db.refresh(device)
return _device_to_out(device)


def _device_to_out(d) -> DeviceOut: # noqa: ANN001 — SQLAlchemy instance, validated via Pydantic
return DeviceOut(
id=d.id,
Expand All @@ -96,6 +121,7 @@ def _device_to_out(d) -> DeviceOut: # noqa: ANN001 — SQLAlchemy instance, val
vendor=d.vendor,
hostname=d.hostname,
os_guess=d.os_guess,
trusted=bool(d.trusted),
first_seen=d.first_seen.isoformat() if d.first_seen else None,
last_seen=d.last_seen.isoformat() if d.last_seen else None,
ports=[
Expand Down Expand Up @@ -142,6 +168,7 @@ def _scan_to_out(s) -> ScanOut: # noqa: ANN001 — SQLAlchemy instance
finished_at=s.finished_at.isoformat() if s.finished_at else None,
duration_seconds=s.duration_seconds,
devices_found=s.devices_found,
current_stage=s.current_stage,
error_message=s.error_message,
warning_message=s.warning_message,
risks_critical=s.risks_critical,
Expand Down
12 changes: 11 additions & 1 deletion backend/app/models/device.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
"""Device and Port SQLAlchemy ORM models."""

from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import relationship

from app.db import Base
Expand All @@ -21,6 +30,7 @@ class Device(Base):
vendor = Column(String, nullable=True) # hardware vendor from arp-scan OUI lookup
hostname = Column(String, nullable=True)
os_guess = Column(String, nullable=True)
trusted = Column(Boolean, nullable=False, default=False, server_default="0")
first_seen = Column(DateTime, default=func.now())
last_seen = Column(DateTime, default=func.now(), onupdate=func.now())

Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class Scan(Base):
finished_at = Column(DateTime, nullable=True)
duration_seconds = Column(Float, nullable=True)
devices_found = Column(Integer, nullable=True)
current_stage = Column(String, nullable=True)
# "scanning" | "analysing" — only set while status="running"
error_message = Column(Text, nullable=True)
warning_message = Column(Text, nullable=True)
# "completed" scans may have a warning_message when nmap failed but ARP succeeded
Expand Down
6 changes: 6 additions & 0 deletions backend/app/scan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,16 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int:

t0 = time.monotonic()
try:
scan.current_stage = "scanning"
db.commit()

result: ScanResult = orchestrate_scan()
_persist_result(db, result)
devices_found = len(result.hosts) + len(result.arp_only)

scan.current_stage = "analysing"
db.commit()

# Run misconfiguration checks against all discovered devices
from app.analysis import (
run_all_checks, # noqa: PLC0415 — deferred to avoid circular import at module level
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/test_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,38 @@ def test_run_all_checks_runs_across_all_devices(db_session):

total = run_all_checks(db_session)
assert total >= 2


@pytest.mark.integration
def test_trusted_device_generates_no_risks(db_session):
"""Devices marked trusted must have all risks cleared and no new ones written."""
from app.analysis import run_checks
from app.db import upsert_device, upsert_port

device = upsert_device(db_session, ip_address="10.99.1.1")
db_session.flush()
upsert_port(db_session, device_id=device.id, port_number=23, service_name="telnet")
db_session.flush()

# First pass generates risks
risks_before = run_checks(db_session, device.id)
db_session.flush()
assert any(r.check_id == "telnet_open" for r in risks_before)

# Mark trusted and re-run
device.trusted = True
db_session.flush()
db_session.expire_all()

risks_after = run_checks(db_session, device.id)
db_session.flush()
assert risks_after == []

# Verify the DB also has no risk rows for this device
from app.models.risk import Risk
from sqlalchemy import select as sa_select

remaining = (
db_session.execute(sa_select(Risk).where(Risk.device_id == device.id)).scalars().all()
)
assert remaining == []
42 changes: 42 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,45 @@ def test_empty_steps_returns_empty_list(client, seeded_db, seeded_risk, db_engin
db2.execute(sqlalchemy.delete(Recommendation).where(Recommendation.id == rec_id))
db2.commit()
db2.close()


def test_patch_device_trusted_toggles_flag(client, seeded_db):
"""PATCH /api/devices/{id}/trusted should toggle the trusted field."""
device_id = seeded_db["device_id"]

# Default is untrusted
resp = client.get(f"/api/devices/{device_id}")
assert resp.status_code == 200
assert resp.json()["trusted"] is False

# Set trusted=True
resp = client.patch(
f"/api/devices/{device_id}/trusted",
json={"trusted": True},
)
assert resp.status_code == 200
assert resp.json()["trusted"] is True

# Set back to False
resp = client.patch(
f"/api/devices/{device_id}/trusted",
json={"trusted": False},
)
assert resp.status_code == 200
assert resp.json()["trusted"] is False


def test_patch_device_trusted_404_for_unknown(client):
"""PATCH /api/devices/{id}/trusted returns 404 for non-existent device."""
resp = client.patch("/api/devices/999999/trusted", json={"trusted": True})
assert resp.status_code == 404


def test_scan_response_includes_current_stage(client, seeded_db):
"""GET /api/scans response must include the current_stage field (may be null)."""
resp = client.get("/api/scans")
assert resp.status_code == 200
data = resp.json()
# The field must be present on every scan record (value may be null)
for scan in data:
assert "current_stage" in scan
47 changes: 43 additions & 4 deletions frontend/src/components/ScanBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
/**
* ScanBanner — top-of-page banner shown while a scan is in progress.
* It pulses gently to indicate activity.
* It pulses gently to indicate activity and shows the current stage + elapsed time.
*/
import { useEffect, useState } from "react";

const STAGE_LABELS: Record<string, string> = {
scanning: "Scanning network…",
analysing: "Analysing risks…",
};

function useElapsed(startedAt: string | null | undefined): string {
const [elapsed, setElapsed] = useState(0);

useEffect(() => {
if (!startedAt) return;
const origin = new Date(startedAt).getTime();
const tick = () =>
setElapsed(Math.max(0, Math.floor((Date.now() - origin) / 1000)));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [startedAt]);

const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}

export interface ScanBannerProps {
scanId?: number | null;
currentStage?: string | null;
startedAt?: string | null;
}

export function ScanBanner({ scanId }: ScanBannerProps) {
export function ScanBanner({
scanId,
currentStage,
startedAt,
}: ScanBannerProps) {
const elapsed = useElapsed(startedAt);
const stageLabel =
(currentStage && STAGE_LABELS[currentStage]) ?? "Scan in progress";

return (
<div
role="status"
Expand All @@ -26,11 +60,16 @@ export function ScanBanner({ scanId }: ScanBannerProps) {
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-accent-primary)]" />
</span>
<span>
Scan in progress
{stageLabel}
{scanId != null && (
<span className="ml-1 font-mono text-xs opacity-70">#{scanId}</span>
)}
… results will update automatically.
</span>
<span
className="ml-auto font-mono text-xs opacity-70"
aria-label={`Elapsed ${elapsed}`}
>
{elapsed}
</span>
</div>
);
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,13 @@ export function DashboardPage() {

return (
<div className="page-enter">
{isRunning && <ScanBanner scanId={activeScanId} />}
{isRunning && (
<ScanBanner
scanId={activeScanId}
currentStage={runningScan?.current_stage}
startedAt={runningScan?.started_at}
/>
)}

<PageHeader title="Dashboard" action={triggerButton} />

Expand Down
46 changes: 44 additions & 2 deletions frontend/src/pages/DeviceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* DeviceDetailPage — ports/services table, risk list, timestamps.
* Route: /devices/:id
*/
import { memo, useMemo } from "react";
import { memo, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Card, Badge, SkeletonCard, PageHeader } from "../components";
import { useDevice, useRisks, useDeviceRecommendations } from "../hooks";
Expand Down Expand Up @@ -76,10 +76,11 @@ const RiskRecPair = memo(function RiskRecPair({
export function DeviceDetailPage() {
const { id } = useParams<{ id: string }>();
const deviceId = Number(id);
const { device, loading, error } = useDevice(deviceId);
const { device, loading, error, refetch } = useDevice(deviceId);
const { risks, loading: risksLoading } = useRisks({ deviceId });
const { recommendations, loading: recsLoading } =
useDeviceRecommendations(deviceId);
const [togglingTrust, setTogglingTrust] = useState(false);

// Build a map of risk_id → recommendation for O(1) lookup
const recByRiskId = useMemo(() => {
Expand Down Expand Up @@ -107,6 +108,21 @@ export function DeviceDetailPage() {
(a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity),
);

const handleToggleTrust = () => {
setTogglingTrust(true);
fetch(`/api/devices/${deviceId}/trusted`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trusted: !device.trusted }),
})
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
})
.then(() => refetch())
.catch(() => {})
.finally(() => setTogglingTrust(false));
};

return (
<div>
<div className="mb-1 flex items-center gap-2 text-sm text-[var(--color-text-secondary)]">
Expand All @@ -124,6 +140,32 @@ export function DeviceDetailPage() {
<PageHeader
title={device.ip_address}
subtitle={device.hostname ?? undefined}
action={
<div className="flex items-center gap-3">
{device.trusted && (
<Badge variant="neutral" className="gap-1">
🛡 Trusted
</Badge>
)}
<button
onClick={handleToggleTrust}
disabled={togglingTrust}
className={[
"rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors",
"focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-primary)]",
device.trusted
? "border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-accent-danger)] hover:text-[var(--color-accent-danger)]"
: "border-[var(--color-accent-primary)]/40 text-[var(--color-accent-primary)] hover:bg-[var(--color-accent-primary)]/10",
].join(" ")}
>
{togglingTrust
? "…"
: device.trusted
? "Untrust device"
: "Mark as trusted"}
</button>
</div>
}
/>

{/* Device metadata */}
Expand Down
Loading