security(discovery): enforce Zone isolation and retire unsafe legacy writers

Bind Agent observations, review, enrichment and telemetry to current Zone
and inventory authority. Preserve immutable MAC ownership and historical
snapshots with stale-result rejection and transactional security audits.

Retire unbound direct/range/scheduled discovery and one/bulk legacy summary
writes in both modes. Require reconciled exact inventory identity or
explicit Zone-scoped IP selection for CSV metadata imports.

Independent Blocker #1 application-security re-audit: PASS on tree
cf863aadc9. Evidence: 141 focused PostgreSQL/HTTP
tests passed; Chromium and PowerShell checks passed. Two documented baseline
source assertions remain unchanged. Production deployment is not approved.
This commit is contained in:
lussierj committed 2026-10-17 00:45:04 +00:00
1 parent e898f283ab
commit 8b0d32cd6b
26 files changed
+3798 -4092

No files matched your search

+15 -1
View File
@@ -2,7 +2,10 @@
[Parameter(Mandatory=$true)]
[string]$IpAddress,
[string]$MinerId = ""
[string]$MinerId = "",
[string]$DiscoveryId = "",
[string]$ObservationVersion = "",
[string]$CommandId = ""
)
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
@@ -164,6 +167,14 @@ $hubUrl = ([string]$config.hub_url).TrimEnd("/")
$secret = [string]$config.agent_secret
$agentId = [string]$config.agent_id
# Revalidate the claimed command against current Hub authority before miner sockets.
if (!$DiscoveryId -or !$ObservationVersion -or !$CommandId) { throw "discovery_observation_required" }
$target = Invoke-RestMethod -Method Post -Uri "$hubUrl/api/agent/discovery-target" `
-Headers @{ "X-MinerOps-Agent-ID" = $agentId; "X-MinerOps-Agent-Secret" = $secret } `
-ContentType "application/json" -Body (@{ discovery_id=$DiscoveryId; version=$ObservationVersion; command_id=$CommandId } | ConvertTo-Json)
$IpAddress = [string]$target.ip_address
if (!$target.ok -or !$IpAddress) { throw "discovery_target_denied" }
$commands = @(
"summary",
"devs",
@@ -219,6 +230,9 @@ if (!$model) {
$payload = [ordered]@{
ip_address = $IpAddress
miner_id = $MinerId
discovery_id = $DiscoveryId
version = $ObservationVersion
command_id = $CommandId
mac_address = $mac
hostname = $hostname
miner_sn = $minerSn
+23 -2
View File
@@ -58,6 +58,21 @@ function Invoke-AgentApi {
"X-MinerOps-Agent-ID" = [string]$config.agent_id
}
$batchField = $null
if ($Path -eq "/api/agent/miner-snapshots") { $batchField = "miners" }
elseif ($Path -eq "/api/agent/discoveries") { $batchField = "discoveries" }
if ($batchField -and $Body -ne $null -and @($Body[$batchField]).Count -gt 128) {
$items = @($Body[$batchField])
$results = @()
for ($offset = 0; $offset -lt $items.Count; $offset += 128) {
$batch = @{}
foreach ($key in $Body.Keys) { $batch[$key] = $Body[$key] }
$last = [Math]::Min($offset + 127, $items.Count - 1)
$batch[$batchField] = @($items[$offset..$last])
$results += Invoke-AgentApi -Method $Method -Path $Path -Body $batch
}
return @{ ok = $true; batches = $results; processed = $items.Count }
}
if ($Body -ne $null) {
$json = $Body | ConvertTo-Json -Depth 100 -Compress
return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -ContentType "application/json" -Body $json -TimeoutSec 180
@@ -252,6 +267,7 @@ function Poll-KnownMiners {
$result = [ordered]@{
miner_id = [string]$Miner.miner_id
miner_version = [string]$Miner.miner_version
ip_address = [string]$Miner.ip_address
mac_address = [string]$Miner.mac_address
room_name = [string]$Miner.room_name
@@ -592,7 +608,7 @@ public static class MinerOpsFastScanner {
}
function Discover-MinersInRanges {
$config = Read-AgentConfig
$config = Invoke-AgentApi -Method "GET" -Path "/api/agent/config"
$ranges = @($config.ip_ranges | Where-Object { $_.enabled -eq $true })
Ensure-FastScannerLoaded
@@ -1015,7 +1031,12 @@ function Process-PendingCommands {
Write-AgentLog "Running Enrich / Match for discovery IP $ipAddress"
$out = powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\MinerOpsAgent\EnrichOneMiner.ps1 -IpAddress $ipAddress 2>&1 | Out-String
$hubConfig = Invoke-AgentApi -Method "GET" -Path "/api/agent/config"
if (-not [string]::Equals([string]$payloadObj.agent_zone_id, [string]$hubConfig.agent_zone_id, [StringComparison]::Ordinal)) {
throw "discovery_command_stale_authority"
}
if (-not $payloadObj.discovery_id -or -not $payloadObj.version) { throw "discovery_observation_required" }
$out = powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\MinerOpsAgent\EnrichOneMiner.ps1 -IpAddress $ipAddress -DiscoveryId ([string]$payloadObj.discovery_id) -ObservationVersion ([string]$payloadObj.version) -CommandId $commandId 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
throw "EnrichOneMiner failed for $ipAddress. Output: $out"
+13 -10
View File
@@ -224,7 +224,9 @@ foreach ($miner in $miners) {
}
$items.Add([pscustomobject]@{
ip = $ip
miner_id = [string]$miner.miner_id
miner_version = [string]$miner.miner_version
ip = $ip
mac = $mac
api3_supported = $false
api3_error = $err
@@ -257,6 +259,8 @@ foreach ($miner in $miners) {
-Request @{ cmd = "get.device.custom_data" }
$items.Add([pscustomobject]@{
miner_id = [string]$miner.miner_id
miner_version = [string]$miner.miner_version
ip = $ip
mac = $mac
api3_supported = $true
@@ -275,15 +279,14 @@ $payload = @{
items = @($items.ToArray())
}
$json = $payload | ConvertTo-Json -Depth 80
Write-Host "Posting API3 enrichment results to hub..."
$result = Invoke-RestMethod `
-Uri "$HubUrl/api/agent/api3-enrichment" `
-Method Post `
-Headers $Headers `
-ContentType "application/json" `
-Body $json
$allItems = @($items.ToArray())
$result = @()
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
$payload.items = @($allItems[$offset..$last])
$result += Invoke-RestMethod -Uri "$HubUrl/api/agent/api3-enrichment" -Method Post `
-Headers $Headers -ContentType "application/json" -Body ($payload | ConvertTo-Json -Depth 80)
}
Write-Host ""
Write-Host "Local checked: $checked"
+9 -12
View File
@@ -120,6 +120,7 @@ foreach ($miner in $knownMiners) {
if ($pools.Count -gt 0) {
$poolSnapshots.Add([pscustomobject]@{
miner_id = $miner.miner_id
miner_version = $miner.miner_version
ip_address = $ip
mac_address = $miner.mac_address
model = $miner.model
@@ -130,18 +131,14 @@ foreach ($miner in $knownMiners) {
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
$body = @{
agent_id = $AgentId
pool_snapshots = $poolSnapshots
} | ConvertTo-Json -Depth 20
$result = Invoke-RestMethod `
-Method Post `
-Uri "$HubUrl/api/agent/pool-snapshots" `
-Headers $Headers `
-Body $body `
-ContentType "application/json" `
-TimeoutSec 180
$allItems = @($poolSnapshots.ToArray())
$result = @()
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
$body = @{agent_id=$AgentId; pool_snapshots=@($allItems[$offset..$last])} | ConvertTo-Json -Depth 80
$result += Invoke-RestMethod -Method Post -Uri "$HubUrl/api/agent/pool-snapshots" `
-Headers $Headers -Body $body -ContentType "application/json" -TimeoutSec 180
}
$result | ConvertTo-Json -Depth 10
@@ -0,0 +1,55 @@
param([string]$AgentRoot = (Join-Path $PSScriptRoot '..'))
$ErrorActionPreference = 'Stop'
$checks=0
foreach ($file in @('MinerOpsAgentService.ps1','EnrichOneMiner.ps1','MinerOpsApi3EnrichmentPoller.ps1','MinerOpsPoolPoller.ps1')) {
$tokens=$null; $errors=$null
$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $AgentRoot $file),[ref]$tokens,[ref]$errors)
if ($errors.Count) { throw "Parse errors in ${file}: $errors" }
$checks++
if ($file -eq 'MinerOpsAgentService.ps1') {
$function=$ast.Find({param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Invoke-AgentApi'},$true)
Invoke-Expression $function.Extent.Text
}
}
function Read-AgentConfig { return @{hub_url='https://hub.invalid';agent_id='simulated-agent';agent_secret='disposable-only'} }
$script:requests=@()
function Invoke-RestMethod {
param($Method,$Uri,$Headers,$ContentType,$Body,$TimeoutSec)
$parsed=$Body | ConvertFrom-Json
$script:requests+=@{body=$parsed;headers=$Headers}
return @{ok=$true}
}
foreach ($selection in @(@{path='/api/agent/discoveries';field='discoveries'},@{path='/api/agent/miner-snapshots';field='miners'})) {
$script:requests=@();$body=@{agent_id='simulated-agent'}
$body[$selection.field]=@(1..257 | ForEach-Object { @{sample=$_;miner_version='signed-version'} })
$result=Invoke-AgentApi -Method POST -Path $selection.path -Body $body
$sizes=@($script:requests | ForEach-Object { @($_.body.($selection.field)).Count })
if (($sizes -join ',') -ne '128,128,1') { throw "Unsafe batching: $sizes" }
foreach ($request in $script:requests) {
if ($request.headers['X-MinerOps-Agent-ID'] -ne 'simulated-agent') { throw 'Identity lost' }
}
if ($result.processed -ne 257) { throw 'Samples lost' }
$checks+=3
}
# Execute the actual target-validation prefix only; transport functions/loops are never loaded.
$source=Get-Content -LiteralPath (Join-Path $AgentRoot 'EnrichOneMiner.ps1') -Raw
$start=$source.IndexOf('$config = Read-AgentConfig')
$finish=$source.IndexOf('$commands = @(',$start)
$prefix=$source.Substring($start,$finish-$start)
$script:targetRequests=0
function Invoke-RestMethod {
param($Method,$Uri,$Headers,$ContentType,$Body)
$script:targetRequests++
if ($Uri -notlike '*/api/agent/discovery-target') { throw 'Unexpected request' }
return @{ok=$true;ip_address='198.51.100.10'}
}
$DiscoveryId='';$ObservationVersion='';$CommandId='';$IpAddress='203.0.113.99'
$denied=$false
try { Invoke-Expression $prefix } catch { $denied=$_.Exception.Message -eq 'discovery_observation_required' }
if (!$denied -or $script:targetRequests -ne 0) { throw 'Missing identity was not rejected before I/O' }
$checks++
$DiscoveryId='00000000-0000-4000-8000-000000000001';$ObservationVersion='version';$CommandId='command'
Invoke-Expression $prefix
if ($IpAddress -ne '198.51.100.10' -or $script:targetRequests -ne 1) { throw 'Unvalidated target retained' }
$checks++
Write-Output "Discovery PowerShell checks passed: $checks; mocked Hub only; zero miner sockets."
+22
View File
@@ -10,6 +10,8 @@ from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
from app.config import settings
from app.discovery.repository import DiscoveryRepository
from fastapi import HTTPException
ConnectionFactory = Callable[[], psycopg.Connection]
@@ -120,9 +122,29 @@ class AgentCommandRepository:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
DiscoveryRepository.lock(cur)
self._retire_unsupported(cur, agent_id)
discovery = DiscoveryRepository(self._connection_factory)
cur.execute(f"""SELECT q.*, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
FROM minerops_agent_command_queue q WHERE agent_id=%s
AND command_status='pending' AND command_type=ANY(%s)
ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT 1000""",
(agent_id,list(READ_ONLY_COMMAND_TYPES)))
pending=list(cur.fetchall())
for command in pending:
try:
discovery.validate_command(cur,agent_id,command)
except HTTPException as exc:
cur.execute("""UPDATE minerops_agent_command_queue SET command_status='cancelled',
completed_at=COALESCE(completed_at,now()), error_message=concat_ws(chr(10),NULLIF(error_message,''),%s::text)
WHERE id=%s""",(exc.detail,command['id']))
DiscoveryRepository.audit(cur,'discovery.command_rejected',None,
{'command_id':command['id'],'agent_id':agent_id,'reason':exc.detail,
'execution_outcome':rejection_execution_outcome({**command,'previous_status':'pending'})})
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
rows = [dict(row) for row in cur.fetchall()]
for row in rows:
discovery.validate_command(cur,agent_id,row)
connection.commit()
return rows
except Exception:
+5
View File
@@ -108,6 +108,11 @@ class AgentZoneRoutingContext(BaseModel):
routing_reason: str | None = None
class AgentDiscoveryMinerMatch(AgentZoneRoutingContext):
miner_id: UUID | None = None
matched_by: str | None = None
class MinerZoneAssociationResult(BaseModel):
agent_zone_id: UUID | None = None
routing_reason: str | None = None
+19 -9
View File
@@ -204,6 +204,8 @@ class AgentZoneRepository:
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
WHERE minerops_site_agents.site_id IS NOT DISTINCT FROM EXCLUDED.site_id
AND minerops_site_agents.agent_zone_id IS NOT DISTINCT FROM EXCLUDED.agent_zone_id
""",
(
agent_id,
@@ -216,6 +218,8 @@ class AgentZoneRepository:
Jsonb(payload),
),
)
if cur.rowcount != 1:
raise AgentZoneConflict("Agent assignment changed concurrently")
connection.commit()
except UniqueViolation as exc:
connection.rollback()
@@ -294,6 +298,20 @@ class AgentZoneRepository:
finally:
connection.close()
def find_miner_in_zone(self, *, agent_id: str, agent_zone_id: UUID,
mac_address: str | None, ip_address: str | None) -> dict | None:
"""Retained helper uses the same atomic authority/collision boundary as HTTP."""
from app.discovery.repository import DiscoveryRepository, reject
repository = DiscoveryRepository(self._connection_factory)
with repository.transaction() as cur:
context = repository.context(cur, agent_id)
if context['agent_zone_id'] != agent_zone_id:
reject('discovery_stale_authority')
if not mac_address and not ip_address:
return None
row = repository.match(cur, context, {'mac_address': mac_address, 'ip_address': ip_address})
return {'id': row['id'], 'matched_by': 'mac' if mac_address else 'ip'} if row else None
def assign_miners_to_zone(
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
) -> dict[UUID, str]:
@@ -326,15 +344,7 @@ class AgentZoneRepository:
elif miner["site_id"] not in {None, zone["site_id"]}:
outcomes[miner_id] = "site_conflict"
else:
cur.execute(
"""
UPDATE miners
SET site_id = %s, agent_zone_id = %s, updated_at = now()
WHERE id = %s AND agent_zone_id IS NULL
""",
(zone["site_id"], agent_zone_id, miner_id),
)
outcomes[miner_id] = "assigned"
outcomes[miner_id] = "miner_zone_unassigned"
connection.commit()
return outcomes
except Exception:
+27
View File
@@ -10,6 +10,7 @@ from app.agent_zones.exceptions import (
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentDiscoveryMinerMatch,
AgentZoneRoutingContext,
AgentZoneCreateRequest,
AgentZoneSummary,
@@ -81,6 +82,8 @@ class AgentZoneService:
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
existing = self.repository.get_agent(authenticated_agent_id)
if not existing or existing.get("agent_zone_id") is None:
raise AgentZoneValidationError("Agent requires an administrator-established Zone assignment")
requested_site = request.site_id
requested_zone = request.agent_zone_id
final_site = requested_site or (existing or {}).get("site_id")
@@ -199,6 +202,30 @@ class AgentZoneService:
],
}
def match_discovered_miner(
self,
agent_id: str,
*,
mac_address: str | None,
ip_address: str | None,
recorded_zone_id: UUID | None = None,
) -> AgentDiscoveryMinerMatch:
routing = self.routing_context(agent_id, recorded_zone_id)
if routing.routing_reason or routing.agent_zone_id is None:
return AgentDiscoveryMinerMatch(**routing.model_dump())
match = self.repository.find_miner_in_zone(
agent_id=agent_id,
agent_zone_id=routing.agent_zone_id,
mac_address=mac_address,
ip_address=ip_address,
)
return AgentDiscoveryMinerMatch(
**routing.model_dump(),
miner_id=match["id"] if match else None,
matched_by=match["matched_by"] if match else None,
)
def associate_miners_from_agent(
self, agent_id: str, miner_ids: list[UUID]
) -> MinerZoneAssociationResult:
+1
View File
@@ -0,0 +1 @@
"""Agent discovery isolation; physical miner writes are never authorized here."""
+96
View File
@@ -0,0 +1,96 @@
"""Exact administrative CSV selection, inside the caller's locked transaction.
CSV metadata never establishes ownership or replaces board identity. Every supplied
identity is a constraint; a failed strong identifier never falls back to an IP.
"""
from uuid import UUID
from fastapi import HTTPException
from app.discovery.repository import address, mac
def resolve_inventory_row(cur, row, row_number, selected_zone=None):
def fail(message, status=409):
raise HTTPException(status_code=status, detail=f"CSV row {row_number}: {message}")
def field(names, normalize=lambda v: v):
values = []
for name in names:
value = row.get(name)
if value is not None and str(value).strip():
try:
values.append(normalize(str(value).strip()))
except (ValueError, HTTPException):
fail(f"invalid {names[0]}", 422)
if len(set(values)) > 1:
fail(f"conflicting {names[0]} columns")
return values[0] if values else None
def uuid(value):
return str(UUID(value))
mid = field(["Miner ID", "Miner UUID", "miner_id", "id"], uuid)
zone = field(["Agent Zone ID", "Zone ID", "agent_zone_id", "zone_id"], uuid)
site = field(["Site ID", "site_id"], uuid)
ip = field(["IP", "IP Address", "ip", "ip_address"], address)
current_mac = field(["MAC Addr", "MAC", "Mac", "mac", "mac_address"], mac)
serial = field(["Serial", "Serial Number", "SN", "Miner SN", "miner_sn", "serial_number"])
if selected_zone:
try:
selected_zone = uuid(selected_zone)
except ValueError:
fail("invalid selected Agent Zone ID", 422)
if zone and zone != selected_zone:
fail("row Zone disagrees with selected Zone")
zone = selected_zone
if zone:
cur.execute("SELECT site_id::text FROM agent_zones WHERE id=%s", (zone,))
zone_row = cur.fetchone()
if not zone_row:
fail("selected Zone does not exist", 422)
if site and site != zone_row["site_id"]:
fail("Site and Zone disagree")
if not (mid or current_mac or serial or (ip and zone)):
fail("provide Miner ID, current MAC, serial, or IP plus Agent Zone ID", 422)
selectors = []
if mid:
selectors.append(("id=%s::uuid", mid))
if current_mac:
selectors.append(("regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')=%s", current_mac.replace(":", "").lower()))
if serial:
selectors.append(("miner_sn=%s", serial))
# IP is only a namespace-scoped selector when no stronger identity is supplied.
if not selectors:
selectors.append(("current_ip=%s::inet AND agent_zone_id=%s::uuid", (ip, zone)))
candidates = {}
for predicate, value in selectors:
params = value if isinstance(value, tuple) else (value,)
cur.execute(
"SELECT id::text, site_id::text, agent_zone_id::text, "
"host(current_ip) AS current_ip, current_mac, miner_sn, model, miner_type, firmware_version "
"FROM miners WHERE " + predicate + " ORDER BY id LIMIT 2 FOR UPDATE",
params,
)
matches = cur.fetchall()
if len(matches) > 1:
fail("ambiguous inventory identity; supply exact Miner ID and consistent identifiers")
candidates.update({m["id"]: m for m in matches})
if not candidates:
return None
if len(candidates) != 1:
fail("identifiers select different inventory records")
miner = next(iter(candidates.values()))
if (
(mid and mid != miner["id"])
or (zone and zone != miner["agent_zone_id"])
or (site and site != miner["site_id"])
or (ip and ip != miner["current_ip"])
or (serial and serial != miner["miner_sn"])
or (current_mac and current_mac != mac(miner["current_mac"]))
):
fail("identifiers disagree with current inventory; refresh selection before importing")
return miner
+42
View File
@@ -0,0 +1,42 @@
"""Bounded HTTP adapter; authentication remains on the registered routes."""
import json
from fastapi import HTTPException
from starlette.concurrency import run_in_threadpool
import psycopg
from app.discovery.repository import DiscoveryRepository
MAX_BODY = 1024 * 1024
async def dispatch(method, request=None, **kwargs):
if request is not None:
chunks = []
size = 0
async for chunk in request.stream():
size += len(chunk)
if size > MAX_BODY:
raise HTTPException(413, "discovery_payload_too_large")
chunks.append(chunk)
try:
def invalid_constant(_):
raise ValueError("Non-finite JSON number")
kwargs["body"] = json.loads(
b"".join(chunks), parse_constant=invalid_constant
)
except (ValueError, UnicodeDecodeError, RecursionError):
raise HTTPException(422, "discovery_invalid_json")
try:
return await run_in_threadpool(getattr(DiscoveryRepository(), method), **kwargs)
except (
psycopg.errors.LockNotAvailable,
psycopg.errors.QueryCanceled,
psycopg.errors.DeadlockDetected,
):
raise HTTPException(409, "discovery_concurrent_change_retry")
except psycopg.DataError:
raise HTTPException(422, "discovery_invalid_value")
except psycopg.IntegrityError:
raise HTTPException(409, "discovery_identity_conflict")
File diff suppressed because it is too large. Load diff
+3
View File
@@ -711,6 +711,7 @@ def machine_agent_request(path: str) -> bool:
"/api/agent/commands/pending-disabled-old",
"/api/agent/commands/result-disabled-old",
"/api/agent/discoveries",
"/api/agent/discovery-target",
"/api/agent/miner-enrichment",
"/api/agent/miner-enrichment-v2",
"/api/agent/miner-enrichment-v3",
@@ -826,6 +827,8 @@ def required_api_permission(method: str, path: str) -> str | None:
return "repairs.complete"
if method == "GET" and repair_timeline_request(path):
return "repairs.read"
if method == "GET" and path in {"/api/agent/discoveries/summary", "/api/agent/status-summary"}:
return "agents.view"
if method == "GET":
if path.startswith("/api/system/"):
return "system_health.view"
+199 -3942
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,49 @@
// Actual Chromium; every HTTP request is fulfilled or aborted, with disposable data only.
const {chromium}=require('playwright');
const fs=require('fs');const assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({headless:true,args:['--no-sandbox','--disable-background-networking']});
try {
const context=await browser.newContext({serviceWorkers:'block'});
const page=await context.newPage();
const marker='<img src="data:," onerror="window.__discoveryAudit=1">';
const commands=[];const unexpected=[];
const observations=[0,1].map(n=>({id:'00000000-0000-4000-8000-00000000000'+(n+1),agent_id:'Agent '+n,agent_zone_id:'Zone '+n,version:'2026-01-01T00:00:00.00000'+n+'+00:00',created_at:'2026-01-01T00:00:00Z',ip_address:'198.51.100.10',room_name:marker,model:marker,mac_address:marker,discovery_status:'new'}));
await page.route('**/*',async route=>{
const url=new URL(route.request().url());
if(url.origin!=='http://minerops.invalid'){unexpected.push(url.href);return route.abort();}
const path=url.pathname;
if(path==='/agent/control')return route.fulfill({contentType:'text/html',body:fs.readFileSync(process.argv[2],'utf8')});
if(path==='/api/agent/status-summary')return route.fulfill({json:{live_database_counts:{total:2,online:2},latest_snapshot:{},agent:{agent_id:marker,hostname:marker,version:marker},recent_commands:[{command_type:marker,command_status:'failed',requested_by_username:marker,error_message:marker}]}});
if(path==='/api/agent/discoveries')return route.fulfill({json:{discoveries:observations}});
if(path==='/api/agent/discoveries/summary')return route.fulfill({json:{total:2,counts:{new:2}}});
if(path==='/api/agent/discoveries/review'||path==='/api/agent/discoveries/enrich-match'){
commands.push({path,body:route.request().postDataJSON()});return route.fulfill({json:{ok:true,message:'Saved'}});
}
return route.abort();
});
await context.addInitScript(()=>localStorage.setItem('minerops_token','disposable-browser-token'));
page.on('dialog',dialog=>dialog.accept());
await page.goto('http://minerops.invalid/agent/control');
await page.waitForSelector('#discoveriesBody tr button');
if(process.argv[3]==='baseline'){
await page.waitForFunction(()=>window.__discoveryAudit===1);
console.log('Baseline positive control: harmless stored event handler executed.');
} else {
assert.equal(await page.evaluate(()=>window.__discoveryAudit),undefined);
assert.equal(await page.locator('#discoveriesBody img,#commandsBody img,[onerror]').count(),0);
assert.ok((await page.locator('#discoveriesBody').innerText()).includes(marker));
await page.locator('#discoveriesBody tr').nth(0).getByRole('button',{name:'Add as New',exact:true}).click();
await page.waitForFunction(()=>document.getElementById('statusBox').textContent==='Agent status loaded.');
await page.locator('#discoveriesBody tr').nth(1).getByRole('button',{name:'Enrich / Match',exact:true}).click();
assert.equal(commands.length,2);
assert.equal(commands[0].body.discovery_id,observations[0].id);
assert.equal(commands[0].body.version,observations[0].version);
assert.equal(commands[1].body.discovery_id,observations[1].id);
assert.equal(commands[1].body.version,observations[1].version);
assert.ok(commands.every(c=>!Object.hasOwn(c.body,'ip_address')));
assert.deepEqual(unexpected,[]);
console.log('Chromium passed: stored markup inert; Agent/status/error data escaped; same-IP actions retain exact observation IDs/versions; no external requests.');
}
} finally {await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1});
@@ -0,0 +1,20 @@
"""Export the literal production page without importing/startup or network access."""
import ast, json, sys
from pathlib import Path
root = Path(sys.argv[1])
tree = ast.parse((root / "backend/app/main.py").read_text())
node = next(
n
for n in tree.body
if isinstance(n, ast.AsyncFunctionDef) and n.name == "minerops_agent_control_page"
)
html = next(
n.value
for n in ast.walk(node)
if isinstance(n, ast.Constant)
and isinstance(n.value, str)
and "<!doctype html>" in n.value
)
Path(sys.argv[2]).write_text(html)
+20 -83
View File
@@ -264,30 +264,13 @@ if __name__ == "__main__":
class AgentTransportIsolationSourceTests(unittest.TestCase):
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
known_start = main.index('@app.get("/api/agent/known-miners")')
known_end = main.index("# Phase 5J:", known_start)
known = main[known_start:known_end]
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
pool_end = main.index("\n@app.", pool_start + 1)
pool = main[pool_start:pool_end]
self.assertIn('"/api/agent/known-miners"', identity)
self.assertIn("Depends(require_agent_identity)", known)
self.assertIn("zone_service.routing_context", known)
self.assertIn("miners.agent_zone_id = $1::uuid", known)
self.assertNotIn("minerops_require_agent_secret", known)
self.assertIn("Depends(require_agent_identity)", pool)
self.assertIn("zone_service.routing_context", pool)
self.assertIn("agent_id = authenticated_agent_id", pool)
self.assertIn("agent_zone_id = %s", pool)
self.assertNotIn('body.get("agent_id")', pool)
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
from app.main import minerops_agent_known_miners, agent_pool_snapshots
from inspect import getsource
for fn,method in [(minerops_agent_known_miners,"known"),(agent_pool_snapshots,"snapshots")]:
source=getsource(fn)
self.assertIn("Depends(require_agent_identity)",source)
self.assertIn("discovery_dispatch",source)
self.assertIn("agent_id=authenticated_agent_id",source)
def test_windows_machine_pollers_send_agent_identity(self):
root = agent_source_root()
@@ -307,67 +290,21 @@ class AgentTransportIsolationSourceTests(unittest.TestCase):
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
decorators = (
'@app.post("/api/agent/miner-enrichment")',
'@app.post("/api/agent/miner-enrichment-v2")',
'@app.post("/api/agent/miner-enrichment-v3")',
)
for decorator in decorators:
with self.subTest(decorator=decorator):
start = main.index(decorator)
end = main.index("\n@app.", start + 1)
route = main[start:end]
self.assertIn("Depends(require_agent_identity)", route)
self.assertIn("zone_service.routing_context", route)
self.assertIn("agent_zone_id", route)
self.assertNotIn("minerops_require_agent_secret", route)
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
v2_end = main.index("\n@app.", v2_start + 1)
v2 = main[v2_start:v2_end]
self.assertIn(
"AND agent_zone_id = $2::uuid",
v2,
)
self.assertIn(
"AND agent_zone_id = $3::uuid",
v2,
)
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
v3_end = main.index("\n@app.", v3_start + 1)
v3 = main[v3_start:v3_end]
self.assertIn(
"AND agent_zone_id = $8::uuid",
v3,
)
self.assertIn(
"AND agent_zone_id = $7::uuid",
v3,
)
from app import main
from inspect import getsource
for fn in [main.minerops_agent_miner_enrichment,main.minerops_agent_miner_enrichment_v2,main.minerops_agent_miner_enrichment_v3]:
source=getsource(fn)
self.assertIn("Depends(require_agent_identity)",source)
self.assertIn("discovery_dispatch",source)
self.assertIn("agent_id=authenticated_agent_id",source)
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
start = main.index('@app.post("/api/agent/api3-enrichment")')
end = main.index("\n@app.", start + 1)
route = main[start:end]
self.assertIn("Depends(require_agent_identity)", route)
self.assertIn("zone_service.routing_context", route)
self.assertIn("agent_id = authenticated_agent_id", route)
self.assertIn("agent_zone_id = %s", route)
self.assertIn("if miner_id is None:", route)
self.assertIn("continue", route)
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
self.assertNotIn('body.get("agent_id")', route)
self.assertNotIn('body.get("site_name")', route)
self.assertNotIn('request.headers.get("x-agent-secret")', route)
from app.main import minerops_agent_api3_enrichment
from inspect import getsource
source=getsource(minerops_agent_api3_enrichment)
self.assertIn("Depends(require_agent_identity)",source)
self.assertIn("discovery_dispatch",source)
self.assertIn("agent_id=authenticated_agent_id",source)
def test_legacy_config_remains_explicitly_legacy_only(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
@@ -0,0 +1,39 @@
"""Reused discovery isolation scenarios exercised through real HTTP authority."""
import unittest
from tests.test_discovery_security_postgres import DiscoveryFixtures
class AgentDiscoveryIsolationPostgresTests(DiscoveryFixtures, unittest.TestCase):
def test_cross_zone_mac_and_ip_are_unmatched_without_id_disclosure(self):
mid, mac = self.miner(1)
before = self.durable()
row = self.observe(mac=mac)
self.assertEqual(row["discovery_status"], "zone_conflict")
self.assertIsNone(row["matched_miner_id"])
self.assertNotIn(str(mid), str(row))
self.assertEqual(before["miners"], self.durable()["miners"])
def test_same_zone_mac_and_ip_matches_are_preserved(self):
mid, mac = self.miner()
row = self.observe(mac=mac)
self.assertEqual(row["matched_miner_id"], str(mid))
self.assertEqual(row["discovery_status"], "known")
def test_unassigned_miner_is_not_claimed_by_discovery_matching(self):
mid, mac = self.miner()
with __import__(
"app.agent_zones.repository", fromlist=["default_connection_factory"]
).default_connection_factory() as conn:
conn.execute("UPDATE miners SET agent_zone_id=NULL WHERE id=%s", (mid,))
row = self.observe(mac=mac)
self.assertIsNone(row["matched_miner_id"])
self.assertIsNone(
self.scalar("SELECT agent_zone_id FROM miners WHERE id=%s", (mid,))
)
def test_claimed_agent_identity_cannot_override_authentication(self):
response = self.post(
"/api/agent/discoveries", {"agent_id": self.agents[1], "discoveries": []}
)
self.assertEqual(response.status_code, 403)
+53 -28
View File
@@ -42,6 +42,7 @@ class FakeRepository:
self.agent = None
self.saved = []
self.assign_result = {MINER_A: "assigned"}
self.discovery_match = None
def get_agent(self, agent_id):
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
@@ -107,6 +108,11 @@ class FakeRepository:
],
}
def find_miner_in_zone(self, *, agent_id, agent_zone_id, mac_address, ip_address):
self.discovery_match_request = {'agent_id':agent_id,'agent_zone_id':agent_zone_id,
'mac_address':mac_address,'ip_address':ip_address}
return self.discovery_match
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
self.assigned = (list(miner_ids), agent_zone_id)
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
@@ -136,6 +142,7 @@ class AgentZoneModelTests(unittest.TestCase):
class AgentZoneServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
self.service = AgentZoneService(self.repository)
def checkin(self, **kwargs):
@@ -144,14 +151,14 @@ class AgentZoneServiceTests(unittest.TestCase):
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
)
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
def test_established_assignment_and_same_assignment_are_allowed(self):
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.assertTrue(first.routing_available)
self.assertEqual(second.agent_zone_id, ZONE_A)
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
self.service.checkin(
@@ -181,34 +188,20 @@ class AgentZoneServiceTests(unittest.TestCase):
self.assertEqual(result.agent_zone_id, ZONE_A)
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
single = self.checkin(site_id=SITE_A)
self.assertEqual(single.agent_zone_id, ZONE_A)
self.repository.agent = None
self.repository.zones = {}
zero = self.checkin(site_id=SITE_A)
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
self.repository.agent = None
self.repository.zones = {
ZONE_A: zone(),
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
}
many = self.checkin(site_id=SITE_A)
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
self.assertIsNone(many.agent_zone_id)
for zones in [{}, {ZONE_A: zone()}, {ZONE_A:zone(),ZONE_B:zone(ZONE_B,SITE_A)}]:
self.repository.zones=zones
self.repository.agent=None
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
self.checkin(site_id=SITE_A)
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
self.repository.zones[ZONE_A]["is_enabled"] = False
unassigned = self.checkin(site_id=SITE_A)
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
self.repository.agent = {
"agent_id": "agent-a", "site_id": SITE_A,
"agent_zone_id": ZONE_A, "site_name": "North",
}
existing = self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-a")
)
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
self.assertEqual(existing.agent_zone_id, ZONE_A)
self.repository.zones[ZONE_A]["is_enabled"]=False
existing=self.service.checkin("agent-a",AgentCheckinRequest(agent_id="agent-a"))
self.assertEqual(existing.routing_reason,"agent_zone_disabled")
self.assertEqual(existing.agent_zone_id,ZONE_A)
self.repository.agent=None
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
self.checkin(site_id=SITE_A)
def test_payload_is_sanitized_before_persistence(self):
request = AgentCheckinRequest.model_validate({
@@ -255,9 +248,41 @@ class AgentZoneServiceTests(unittest.TestCase):
self.assertNotIn("10.80.0.0/24", str(configuration))
def test_discovery_match_uses_only_authenticated_agent_zone(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.discovery_match = {"id": MINER_A, "matched_by": "mac"}
result = self.service.match_discovered_miner(
"agent-a",
mac_address="02:00:00:00:16:21",
ip_address="192.0.2.21",
)
self.assertEqual(result.miner_id, MINER_A)
self.assertEqual(result.matched_by, "mac")
self.assertEqual(
self.repository.discovery_match_request["agent_zone_id"], ZONE_A
)
def test_discovery_match_rejects_stale_recorded_zone_without_querying(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
result = self.service.match_discovered_miner(
"agent-a",
recorded_zone_id=ZONE_B,
mac_address="02:00:00:00:16:21",
ip_address="192.0.2.21",
)
self.assertIsNone(result.miner_id)
self.assertEqual(result.routing_reason, "agent_zone_conflict")
self.assertFalse(hasattr(self.repository, "discovery_match_request"))
class AgentZoneRouterTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
self.service = AgentZoneService(self.repository)
self.app = FastAPI()
self.app.include_router(router)
+3 -3
View File
@@ -196,12 +196,12 @@ class AgentZonePostgresTests(unittest.TestCase):
connection.rollback()
connection.close()
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
def test_unassigned_miner_stays_unassigned_and_existing_other_zone_wins(self):
repository = AgentZoneRepository(connect)
outcomes = repository.assign_miners_to_zone(
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
)
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
self.assertEqual(outcomes[MINER_UNASSIGNED], "miner_zone_unassigned")
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
connection = connect()
try:
@@ -213,7 +213,7 @@ class AgentZonePostgresTests(unittest.TestCase):
assignments = dict(cur.fetchall())
finally:
connection.close()
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
self.assertIsNone(assignments[MINER_UNASSIGNED])
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
def test_disabled_zone_blocks_control_without_site_fallback(self):
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,338 @@
"""Audit F1-F3 reversals: real registered HTTP/transactions, no physical devices."""
import ast
from concurrent.futures import ThreadPoolExecutor
import os
from pathlib import Path
import threading
import time
import unittest
from unittest.mock import patch
from uuid import uuid4
from fastapi import HTTPException
import app.main as main
from app.identity import create_access_token
from tests.test_discovery_security_postgres import DiscoveryFixtures, connect
class LegacyInventorySecurityTests(DiscoveryFixtures, unittest.TestCase):
def upload(self, text, query='', headers=None):
return self.client.post(
'/api/imports/whatsminer-tool-scan?dry_run=false&update_existing=true' + query,
headers=self.admin if headers is None else headers,
files={'file': ('simulated.csv', text.encode(), 'text/csv')},
)
def denied(self, response, before, status):
self.assertEqual(response.status_code, status, response.text)
self.assertEqual(before, self.durable())
self.transport.assert_not_called()
def test_discovery_audit_reproductions_all_rejected_without_reads(self):
self.miner(1, ip='198.51.100.111')
self.miner(ip='198.51.100.125')
self.miner(1, ip='198.51.100.125')
for mode in ['true', 'false']:
for ip in ['198.51.100.111', '198.51.100.125', '203.0.113.209']:
with self.subTest(mode=mode, ip=ip), patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
before = self.durable()
response = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': ip})
self.denied(response, before, 410)
def test_missing_disabled_agent_and_zone_cannot_restore_legacy_discovery(self):
with connect() as c:
c.execute('UPDATE agent_zones SET is_enabled=false WHERE id=%s', (self.zones[0],))
c.execute('UPDATE minerops_site_agents SET credential_revoked_at=now() WHERE agent_id=%s', (self.agents[0],))
c.execute('DELETE FROM minerops_site_agents WHERE agent_id=%s', (self.agents[1],))
before = self.durable()
r = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': '198.51.100.111'})
self.denied(r, before, 410)
def test_inventory_only_permission_cannot_replace_mac(self):
role, uid = uuid4(), uuid4()
with connect() as c:
c.execute('INSERT INTO roles(id,name) VALUES (%s,%s)', (role, str(role)))
c.execute("INSERT INTO role_permissions(role_id,permission_id) SELECT %s,id FROM permissions WHERE key='miners.update'", (role,))
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) VALUES (%s,%s,'Inventory only','unused',%s)", (uid, str(uid), role))
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), str(role))}
mid, _ = self.miner(ip='198.51.100.127')
before = self.durable()
for path, body, status in [
('/api/agent/discoveries/review', {'discovery_id': str(uuid4()), 'version': 'x', 'action': 'ignore'}, 403),
(f'/api/miners/{mid}/replace-control-board', {}, 403),
('/api/miners/discover-one', {'ip_address': '198.51.100.127'}, 410),
]:
self.denied(self.client.post(path, headers=headers, json=body), before, status)
def test_range_and_manual_scheduler_retired_in_both_modes(self):
for mode in ['true', 'false']:
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
for path in [f'/api/scan-ranges/{uuid4()}/discover-now', '/api/scanner/run-known-now']:
before = self.durable()
self.denied(self.client.post(path, headers=self.admin, json={}), before, 410)
with patch('app.main.threading.Thread') as thread:
main.start_scheduled_scanner()
thread.assert_not_called()
status = self.client.get('/api/scanner/status', headers=self.admin).json()['data']
self.assertFalse(status['enabled'])
self.assertTrue(status['scanner_disabled'])
def test_all_legacy_helper_definitions_and_saved_aliases_are_retired(self):
# Execute every original definition, including shadowed functions; decorators
# are omitted so this cannot mutate the application's registered routes.
tree = ast.parse(Path(main.__file__).read_text())
names = {'find_existing_miner', 'discover_one_miner', 'discover_scan_range_now',
'call_discover_one_miner_flexible', 'refresh_one_known_miner_for_schedule',
'run_scheduled_known_miner_scan', 'scheduled_scanner_loop',
'apply_legacy_summary_to_miner', 'legacy_find_miner_id_by_ip'}
import inspect
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name in names:
with self.subTest(helper=node.name, line=node.lineno):
node.decorator_list = []
namespace = dict(vars(main))
exec(compile(ast.Module(body=[node], type_ignores=[]), '<retired-helper>', 'exec'), namespace)
fn = namespace[node.name]
kwargs = {name: None for name, p in inspect.signature(fn).parameters.items() if p.default is inspect.Parameter.empty}
with patch('app.main.get_db_connection', side_effect=AssertionError('No legacy database access')):
with self.assertRaises(HTTPException) as exc:
fn(**kwargs)
self.assertEqual(exc.exception.status_code, 410)
with self.assertRaises(HTTPException) as exc:
main._minerops_original_discover_one_miner_legacy_summary(None)
self.assertEqual(exc.exception.status_code, 410)
def test_summary_id_ip_disagreement_and_bulk_retired(self):
a, _ = self.miner(ip='198.51.100.121')
self.miner(1, ip='198.51.100.122')
with patch('app.main.read_legacy_whatsminer_summary', side_effect=AssertionError('No summary transport')) as read:
for mode in ['true', 'false']:
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
for path, body in [
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a), 'ip_address': '198.51.100.122'}),
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a)}),
('/api/tools/legacy-summary-refresh-missing', {}),
]:
before = self.durable()
self.denied(self.client.post(path, headers=self.admin, json=body), before, 410)
read.assert_not_called()
def test_summary_cannot_start_inflight_read_or_overwrite_newer_owner(self):
mid, _ = self.miner(ip='198.51.100.123')
with patch('app.main.read_legacy_whatsminer_summary') as read, ThreadPoolExecutor(2) as pool:
future = pool.submit(lambda: self.client.post('/api/tools/legacy-summary-refresh-one', headers=self.admin, json={'miner_id': str(mid)}))
with connect() as c:
c.execute("UPDATE miners SET site_id=%s,agent_zone_id=%s,current_ip='198.51.100.124',updated_at=clock_timestamp() WHERE id=%s", (self.sites[1], self.zones[1], mid))
self.assertEqual(future.result(timeout=8).status_code, 410)
before = self.durable()
with self.assertRaises(HTTPException):
main.apply_legacy_summary_to_miner(str(mid), '198.51.100.123')
self.assertEqual(before, self.durable())
self.assertEqual(self.scalar('SELECT host(current_ip) FROM miners WHERE id=%s', (mid,)), '198.51.100.124')
read.assert_not_called()
def test_legacy_human_auth_still_required(self):
for headers in [{}, self.headers()]:
for path in ['/api/miners/discover-one', '/api/tools/legacy-summary-refresh-one']:
before = self.durable()
self.denied(self.client.post(path, headers=headers, json={'ip_address': '198.51.100.111'}), before, 401)
def test_csv_ip_only_requires_zone_even_with_one_global_match(self):
self.miner(1, ip='198.51.100.112')
before = self.durable()
self.denied(self.upload('IP,Miner Type\n198.51.100.112,WRONG\n'), before, 422)
def test_csv_same_ip_requires_namespace_and_exact_zone_updates_one(self):
a, _ = self.miner(ip='198.51.100.126')
b, _ = self.miner(1, ip='198.51.100.126')
before = self.durable()
self.denied(self.upload('IP,Miner Type\n198.51.100.126,WRONG\n'), before, 422)
r = self.upload(f'IP,Agent Zone ID,Miner Type\n198.51.100.126,{self.zones[1]},VALID\n')
self.assertEqual(r.status_code, 200, r.text)
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
def test_csv_duplicate_ip_inside_zone_rejected(self):
self.miner(ip='198.51.100.130'); self.miner(ip='198.51.100.130')
before = self.durable()
self.denied(self.upload('IP,Miner Type\n198.51.100.130,WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
def test_csv_exact_id_mac_serial_and_query_zone_legitimate(self):
mid, mac = self.miner(ip='198.51.100.131')
serial = uuid4().hex
with connect() as c:
c.execute('UPDATE miners SET miner_sn=%s WHERE id=%s', (serial, mid))
for heading, value in [('Miner ID', str(mid)), ('MAC', mac), ('Serial', serial), ('IP', '198.51.100.131')]:
r = self.upload(f'{heading},Miner Type\n{value},VALID\n', '&agent_zone_id=' + str(self.zones[0]))
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.json()['data']['matched_count'], 1)
def test_csv_conflicting_and_unknown_mac_never_fall_back_to_ip(self):
a, _ = self.miner(ip='198.51.100.132')
b, bm = self.miner(1, ip='198.51.100.133')
for mac in [bm, '02:AA:BB:CC:DD:EF']:
before = self.durable()
self.denied(self.upload(f'Miner ID,IP,MAC,Miner Type\n{a},198.51.100.132,{mac},WRONG\n'), before, 409)
before = self.durable()
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.133,WRONG\n'), before, 409)
def test_csv_historical_mac_and_contradictory_namespace_serial_denied(self):
mid, oldmac = self.miner(ip='198.51.100.134')
with connect() as c:
c.execute('UPDATE miners SET current_mac=%s,miner_sn=%s WHERE id=%s', ('02:' + ':'.join(f'{v:02X}' for v in uuid4().bytes[:5]), 'serial-' + str(mid), mid))
for columns, values in [
('MAC', oldmac), ('Serial', 'unknown'), ('Site ID', str(self.sites[1])),
('Agent Zone ID', str(self.zones[1])), ('miner_id', str(uuid4())),
]:
before = self.durable()
self.denied(self.upload(f'Miner ID,{columns},Miner Type\n{mid},{values},WRONG\n'), before, 409)
def test_csv_invalid_identifiers_and_query_row_disagreement(self):
mid, _ = self.miner(ip='198.51.100.135')
for header, value in [('MAC', 'bad-mac'), ('IP', '198.51.100.135/24'), ('Miner ID', 'bad-id'), ('Agent Zone ID', 'bad-zone')]:
before = self.durable()
self.denied(self.upload(f'{header},Miner Type\n{value},WRONG\n'), before, 422)
before = self.durable()
self.denied(self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[1]},WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
def test_csv_unmatched_strong_identity_does_not_select_ip(self):
self.miner(ip='198.51.100.136')
before = self.durable()
r = self.upload('MAC,IP,Miner Type\n02:AA:BB:CC:DD:EF,198.51.100.136,WRONG\n')
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.json()['data']['unmatched_count'], 1)
self.assertEqual(before['miners'], self.durable()['miners'])
def test_csv_batch_conflict_rolls_back_prior_valid_row(self):
a, _ = self.miner(ip='198.51.100.137')
b, _ = self.miner(1, ip='198.51.100.138')
before = self.durable()
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.137,VALID\n{b},198.51.100.137,WRONG\n'), before, 409)
def test_csv_preview_repeat_and_history_preservation(self):
mid, _ = self.miner(ip='198.51.100.139')
with connect() as c:
c.execute('INSERT INTO miner_status_snapshots(id,miner_id,observed_at,mhs_av) VALUES (%s,%s,now(),123)', (uuid4(), mid))
before = self.durable()
text = f'Miner ID,Miner Type\n{mid},VALID\n'
r = self.client.post('/api/imports/whatsminer-tool-scan?dry_run=true&update_existing=true', headers=self.admin, files={'file': ('simulated.csv', text.encode(), 'text/csv')})
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(before, self.durable())
for _ in range(2):
self.assertEqual(self.upload(text).status_code, 200)
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
self.assertEqual(before['miner_mac_ownerships'], self.durable()['miner_mac_ownerships'])
def test_csv_actual_audit_permission_failure_rolls_back(self):
mid, _ = self.miner(ip='198.51.100.140')
role = 'b1_csv_' + uuid4().hex
with connect() as c:
c.execute('CREATE ROLE ' + role)
c.execute('GRANT USAGE ON SCHEMA public TO ' + role)
c.execute('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO ' + role)
c.execute('REVOKE INSERT ON audit_logs FROM ' + role)
def limited():
c = connect(); c.execute('SET ROLE ' + role); c.commit(); return c
before = self.durable()
try:
with patch('app.main.get_db_connection', side_effect=limited):
r = self.upload(f'Miner ID,Miner Type\n{mid},ROLLBACK\n')
self.denied(r, before, 500)
self.assertIn('audit_logs', r.text)
finally:
with connect() as c:
c.execute('DROP OWNED BY ' + role); c.execute('DROP ROLE ' + role)
def test_csv_concurrent_ownership_move_is_revalidated_after_real_lock_wait(self):
mid, _ = self.miner(ip='198.51.100.141')
owner = connect()
owner.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
with ThreadPoolExecutor(1) as pool:
future = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},WRONG\n'))
try:
deadline = time.monotonic() + 4
waiting = 0
while time.monotonic() < deadline:
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
if waiting: break
time.sleep(.02)
self.assertGreater(waiting, 0)
owner.commit()
r = future.result(timeout=8)
self.assertEqual(r.status_code, 409, r.text)
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (mid,)))
finally:
owner.rollback(); owner.close()
def test_csv_real_update_lock_prevents_mid_transaction_ownership_change(self):
import app.discovery.csv_import as module
mid, _ = self.miner(ip='198.51.100.142')
entered, release = threading.Event(), threading.Event()
original = module.resolve_inventory_row
def gate(*args, **kwargs):
row = original(*args, **kwargs); entered.set()
self.assertTrue(release.wait(4)); return row
def move():
with connect() as c:
c.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
with patch.object(module, 'resolve_inventory_row', side_effect=gate), ThreadPoolExecutor(2) as pool:
upload = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},VALID\n'))
self.assertTrue(entered.wait(4)); moving = pool.submit(move)
try:
deadline = time.monotonic() + 3
waiting = 0
while time.monotonic() < deadline:
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
if waiting: break
time.sleep(.02)
self.assertGreater(waiting, 0)
finally:
release.set()
self.assertEqual(upload.result(timeout=8).status_code, 200)
moving.result(timeout=8)
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
def test_csv_duplicate_headers_and_conflicting_aliases_rejected(self):
mid, _ = self.miner(ip='198.51.100.143')
for text, code in [
(f'Miner ID,Miner ID,Miner Type\n{mid},{mid},WRONG\n', 422),
(f'Miner ID,miner_id,Miner Type\n{mid},{uuid4()},WRONG\n', 409),
(f'Miner ID,Site ID,Agent Zone ID,Miner Type\n{mid},{self.sites[1]},{self.zones[0]},WRONG\n', 409),
]:
before = self.durable()
self.denied(self.upload(text), before, code)
def test_csv_duplicate_serial_is_ambiguous(self):
a, _ = self.miner(ip='198.51.100.144')
b, _ = self.miner(1, ip='198.51.100.145')
serial = 'duplicate-' + uuid4().hex
with connect() as c:
c.execute('UPDATE miners SET miner_sn=%s WHERE id IN (%s,%s)', (serial, a, b))
before = self.durable()
self.denied(self.upload(f'Serial,Miner Type\n{serial},WRONG\n'), before, 409)
def test_csv_global_mac_selection_with_overlapping_ip_is_valid(self):
a, _ = self.miner(ip='198.51.100.146')
b, mac = self.miner(1, ip='198.51.100.146')
before = self.durable()
r = self.upload(f'MAC,IP,Miner Type\n{mac},198.51.100.146,VALID\n')
self.assertEqual(r.status_code, 200, r.text)
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
after = self.durable()
for table in ['miner_status_snapshots', 'miner_mac_ownerships', 'miner_network_identities']:
self.assertEqual(before[table], after[table])
audit = self.scalar("SELECT new_value_json FROM audit_logs WHERE actor_user_id=%s AND action='import.whatsminertool_csv.applied' ORDER BY created_at DESC LIMIT 1", (self.uid,))
self.assertEqual(audit['updates'][0]['miner_id'], str(b))
self.assertEqual(audit['updates'][0]['agent_zone_id'], str(self.zones[1]))
def test_csv_read_only_user_cannot_apply_metadata(self):
uid = uuid4()
with connect() as c:
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Read only','unused',id FROM roles WHERE name='Read Only'", (uid, str(uid)))
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), 'Read Only')}
mid, _ = self.miner(ip='198.51.100.147')
before = self.durable()
self.denied(self.upload(f'Miner ID,Miner Type\n{mid},WRONG\n', headers=headers), before, 403)
@@ -146,8 +146,23 @@ class StageAQueueContainmentTests(unittest.TestCase):
self.assertEqual(self.audits(command_id), [])
def test_read_only_commands_still_claim_and_complete(self):
for kind in ("poll_known_miners_now", "discover_new_miners", "enrich_match_discovery"):
self.seed(f"stage-a-read-{kind}", command_type=kind, payload={"ip_address": "192.0.2.1"})
from uuid import uuid4
from app.discovery.repository import DiscoveryRepository
site, zone, actor = uuid4(), uuid4(), uuid4()
with connect() as connection, connection.cursor() as cur:
cur.execute("INSERT INTO sites(id,name) VALUES (%s,%s)",(site,str(site)))
cur.execute("INSERT INTO agent_zones(id,site_id,name) VALUES (%s,%s,'Stage A reader')",(zone,site))
cur.execute("INSERT INTO agent_zone_network_ranges(agent_zone_id,network) VALUES (%s,'192.0.2.0/24')",(zone,))
cur.execute("""INSERT INTO minerops_site_agents(agent_id,site_id,agent_zone_id,site_name)
VALUES (%s,%s,%s,'Stage A reader') ON CONFLICT(agent_id) DO UPDATE SET
site_id=EXCLUDED.site_id,agent_zone_id=EXCLUDED.agent_zone_id""",(AGENT,site,zone))
cur.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Reader','unused',id FROM roles WHERE name='Admin'",(actor,str(actor)))
discovery=DiscoveryRepository(connect)
observation=discovery.ingest(AGENT,{'discoveries':[{'ip_address':'192.0.2.1'}]})['discoveries'][0]
user={'id':str(actor),'username':'stage-a-reader'}
for kind in ('poll_known_miners_now','discover_new_miners'):
discovery.queue({'agent_id':AGENT},user,kind)
discovery.queue({'discovery_id':observation['id'],'version':observation['version']},user)
commands = self.service.claim(AGENT)
self.assertEqual(len(commands), 3)
for command in commands:
@@ -0,0 +1,219 @@
Blocker #1 discovery isolation: candidate review and compatibility contract
=======================================================================
Review candidate only. Blocker #1 is NOT declared PASS. No production cutover is
authorized. Base: e898f283ab0fc21bac77b63db1c4a9ad35e21562 (Stage A / draft PR #6).
No schema migration, new constraints, backfill, or runtime DDL is introduced.
Authority and observations
-------------------------
All machine discovery/telemetry routes retain per-Agent HTTP authentication.
Authority comes from the persisted Agent -> Site/Zone/network assignment, never
client Site, room, Zone, target-Agent names, or a body-supplied Agent identity.
Conflicting body Agent IDs are rejected. Disabled/unassigned/revoked routing fails
closed. Check-in cannot establish or change an Agent's initial assignment: use
existing authenticated enrollment or the separately reviewed legacy transition.
Unassigned inventory is not implicitly claimed by check-in or snapshots.
Discovery reports are bounded (128 items, 1 MiB body; 1,024 observations/minute per
Agent). IP literals must be in the current Zone networks. MACs must be canonical
ASCII unicast addresses. Explicit IDs, IP aliases, MAC aliases and supplied serial
must agree with current inventory and immutable historical MAC ownership. Global
historical MAC/serial collision checks reveal no foreign inventory IDs. Duplicate
current IPs within a Zone are conflicts; distinct Zones may use the same IP.
Each observation has a stable server ID and version. Raw reports and original
provenance are retained, with an atomic discovery.observed audit recording the
current authority, raw hash, inventory fingerprint and observation version. Exact
retries deduplicate; discovery clients must send discovered_at for a fresh scan
(the supplied Windows scanner does). Approval requires a current, proven,
non-superseded observation less than 15 minutes old. Legacy/null/mislinked rows
without this evidence remain visible but cannot authorize approval or enrichment.
There is no automatic evidence backfill, deletion, identity merge or ownership
transfer. An authorized operator must investigate conflicts and obtain a fresh
observation. Discovery does not implement an ownership-transfer workflow.
Human policy remains global administration: agents.view for discovery display;
agents.manage for stable-ID review/enrichment; miners.control for scan/poll
requests. The UI displays authoritative Agent/Zone and escapes all untrusted text.
IP-only approval is unsupported. Submit discovery_id, version and one of
add_to_inventory / ignore / non_miner. All three revalidate current authority and
identity under locks; only that observation changes. Adding new inventory also
requires a valid MAC. Repeat the same accepted decision/version to receive its
recorded response without another audit or insertion. Stale/different decisions
conflict; refresh before retrying. Audit actor is the authenticated human.
Read commands and enrichment
----------------------------
Enrichment is selected by discovery_id/version, routed to its actual observing
Agent, and bound to the current authority and IP. No arbitrary-IP or hardcoded
Plexus fallback exists. Scan/poll requests require an explicit registered Agent ID;
optional UUID request_id provides explicit retry idempotency. New requests without
request_id receive distinct IDs. Queue claim rejects legacy/unscoped/stale reads
with atomic cancellation/audit and preserves unknown delivery outcome for any
previous claim evidence. Existing Stage A physical rejection remains in force.
Before enrichment miner I/O, the upgraded Windows child submits command_id,
discovery_id and version to authenticated POST /api/agent/discovery-target. Only
a current claimed enrichment command returns a canonical IP. Enrichment submission
revalidates the same binding and appends a NEW child observation for human review;
it never resets the parent's decision or reparents history. V1/V2/V3 share the
same boundary. Replaying old V2 identity merge inputs cannot merge inventory.
Known-miners returns a short-lived signed miner_version bound to Agent authority
and the current miner ID/IP/MAC/updated_at fingerprint. Every miner/pool/API3
snapshot or known-miner enrichment must carry this token and matching identifiers.
All Windows polling clients propagate it and batch at 128. A concurrent update,
revocation or reassignment may invalidate in-flight telemetry: discard the stale
response, refetch known-miners and poll again. Do not fall back to IP matching.
Descriptive enrichment does not change MAC/IP/serial/ownership. Raw diagnostics
are retained in append-only audits, not empty status rows that hide measurements.
API3 retains raw snapshots, safe descriptive metadata, actual summary/pool/board
measurements and compatibility counters. Failed/metadata-only API3 responses do
not replace the most recent measurements. Nested identity contradictions fail.
Transactions and database roles
------------------------------
All discovery writers/read-command claims take bounded locks in a shared order:
advisory transaction lock 743190031, SHARE on Agent/Zone/network tables, SHARE ROW
EXCLUSIVE on miners, then row locks. These table locks prevent phantom inserts
from legacy writers which do not use the advisory lock. Agent routing/check-in
uses guarded updates. No miner network I/O occurs while these transactions run.
Audit insertion, inventory mutation and the observation decision commit together;
any error rolls back all changes. Audit-denial and real UPDATE-trigger failure,
concurrent approvals, cross-Zone MAC races, Zone disable and claim-vs-review are
covered with real PostgreSQL connections and registered HTTP authorization routes.
Lock timeout is 5 seconds; statement timeout 15 seconds. Controlled contention
returns conflict/retry. Security favors correctness over fleet throughput here.
Required release review: validate privileges/lock behavior for the actual deployed
DB role in a disposable replica and measure expected fleet load. Tests use an
isolated database owner plus a limited role with audit INSERT denied. They do not
certify the production role. No new uniqueness constraint is proposed, so do not
remove the transaction/table locks without a separately reviewed replacement.
Reproduction
------------
Use a disposable checkout of this candidate with an already available backend
image containing backend/requirements.txt. Never mount production credentials or
.env files. From the candidate root:
MINEROPS_REVIEW_BACKEND_IMAGE=<existing-image> scripts/test-discovery-security.sh
The runner creates a unique internal Docker network, tmpfs PostgreSQL 16, no
published ports, applies the unchanged existing Alembic chain and runs the two
PostgreSQL discovery suites. A trap destroys only its own resources. Tests use
simulated Agents and FastAPI's registered routes, without app startup/schedulers.
PowerShell (isolated PowerShell 7 host/container with simulated/loopback I/O):
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsDiscovery.ps1
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsDiagnosticTransport.ps1
Browser (Python 3, Node, Playwright and its Chromium/runtime installed externally):
python3 backend/tests/browser/export_discovery_page.py . /tmp/discovery-page.html
node backend/tests/browser/discovery-security.cjs /tmp/discovery-page.html
The browser test intercepts all requests, supplies harmless event-marker input,
and verifies inert rendering and exact same-IP discovery_id/version selection.
Its trailing baseline argument demonstrates the baseline stored-markup defect.
Other regression suites (explicit disposable DB guards apply):
tests.test_stage_a_containment_postgres
tests.test_stage_a_delivery_evidence_postgres
tests.test_stage_a_schedule_cancellation_postgres
tests.test_agent_zones_postgres
tests.test_agent_credential_transition_postgres
tests.test_agent_enrollment_postgres
Baseline source-test exceptions retained unchanged
-------------------------------------------------
backend/tests/test_agent_commands.py:
AgentEnrichmentIsolationSourceTests.test_legacy_config_remains_explicitly_legacy_only
requires the old minerops_require_agent_secret source string / fixed Plexus config.
The authenticated config route already changed before this candidate. Replace the
obsolete source assertion with real legacy/authenticated route tests in separate
work; do not weaken credential authentication to satisfy the string assertion.
AgentSourceContractTests.test_deployment_preserves_local_state_and_registers_established_tasks
blanket-bans the quoted agent-config.json filename in installer source, although
the existing credential-recovery installer legitimately preserves/recovers local
configuration. Follow up with behavioral installation/state-preservation tests on
Windows. Do not remove the recovery path to satisfy the blanket source assertion.
Both failures reproduce on unchanged 2d89e80 and unchanged e898f28; both method
bodies remain identical. They are not silent skips or weakened assertions here.
The external-fixture legacy-retirement PostgreSQL test class also requires its
own migration-case setup and is not certified by the discovery test runner.
Outstanding gates
-----------------
Independent security review of this exact isolated candidate is required before
any Blocker #1 PASS or commit approval. Native Windows PowerShell 5.1/SYSTEM and
fleet-scale/production-role validation remain outstanding. Unsupported older
Agent payloads fail closed and require coordinated upgrade; this is an intentional
protocol compatibility change, not transparent mixed-version support.
Stage A application containment remains a separate audited boundary. Physical
execution is disabled; device identity binding and Blocker #3 Stages B-E remain
outstanding. Preserve STAGE_A_OLD_AGENT_CUTOVER.txt: verified network fencing must
precede stopping/draining/upgrading old Agents. Cached commands cannot be revoked
by server cancellation alone. Offline/unreachable Agents stay blocked; restart,
reconnection and rollback must not restore an unsafe executor. This candidate
neither performs nor authorizes that cutover or any production deployment.
Independent-audit remediation addendum (October 16, 2026)
-------------------------------------------------------
The original candidate (99f515924dfc85fe1e1e98361688f1060329c090) failed independent
review for F1-F3 in retained legacy callers. This corrected candidate requires a
new independent audit. It does not claim Blocker #1 PASS.
Retired in Hub AND non-Hub modes, with HTTP 410 after existing authentication and
permission checks:
POST /api/miners/discover-one
POST /api/scan-ranges/{scan_range_id}/discover-now
POST /api/scanner/run-known-now
POST /api/tools/legacy-summary-refresh-one
POST /api/tools/legacy-summary-refresh-missing
All definitions/aliases of their identity matcher, discovery writer, scheduler
adapters, summary writer and global-IP helper reject before transport/database
access. Startup creates no local scanner thread; status reports retired/disabled.
Ordinary miners.update therefore cannot use discovery to replace a control board
or clear a MAC. The dedicated board workflow and immutable MAC ownership remain.
Functional implication: legacy local discovery, automatic local refresh, and
legacy one/bulk summary backfill are unavailable even outside Hub mode. Operators
must use authenticated Agent observation/review and inventory-bound Agent polling.
Legacy summary replies lack trustworthy identity; there is no retained unversioned
write or in-flight read to revalidate. No denied request mutates inventory, audit,
or telemetry. The new shared discovery architecture is preserved.
CSV remains an explicit global administrative workflow protected by miners.update.
Each row requires Miner ID, current MAC, serial, or IP plus Agent Zone ID. Zone may
come from the query/UI input or a row column; conflicting selections are rejected.
All supplied ID/MAC/serial/IP/Site/Zone evidence must agree with current inventory.
A failed MAC/serial/ID never falls back to global IP. Missing inventory is reported
unmatched; contradictory or ambiguous selection returns row-specific 409, malformed
or insufficient identification 422. A failure rolls back the entire import.
IP-only rows need a Zone even when only one global match currently exists. Exact
MAC/ID/serial selection remains usable by global administrators across Sites.
CSV updates only model/type/firmware, never ownership, IP, MAC or telemetry.
Duplicate columns are rejected rather than silently using the last value.
CSV acquires the existing discovery lock order, including the miners table lock,
before resolving any row. It holds the locks through metadata changes and one
atomic audit containing every changed inventory ID/namespace and old/new values.
Lock contention is bounded by existing discovery timeouts; large fleet throughput
and actual deployed-role privileges still require independent release validation.
Preview uses identical identification checks and rolls back. Repeated imports do
not create inventory or telemetry; each applied request retains its import audit.
No migration or containment/control policy changes are part of this remediation.
Regression entrypoint: scripts/test-discovery-security.sh now includes
tests.test_legacy_inventory_security_postgres
with registered HTTP requests, actual PostgreSQL locks/concurrent ownership moves,
actual audit INSERT denial rollback, retired helper/alias coverage, both modes,
permission boundaries, ambiguous/conflicting CSV selection, legitimate imports,
preview/replay, and retained historical snapshots. All devices are simulated or
transport-patched; the runner uses an internal network and disposable database.
Full execution evidence and integrity manifest are retained outside the candidate
in the remediation evidence directory referenced by the final report.
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Run ONLY from the isolated review candidate. No .env files or published DB ports.
set -euo pipefail
review_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
: "${MINEROPS_REVIEW_BACKEND_IMAGE:?Set an existing Python backend image with requirements installed}"
docker image inspect "$MINEROPS_REVIEW_BACKEND_IMAGE" >/dev/null
review_id="minerops-discovery-review-$$-$RANDOM"
review_network="$review_id-network"
review_db="$review_id-postgres"
cleanup() {
docker rm -f "$review_db" >/dev/null 2>&1 || true
docker network rm "$review_network" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker network create --internal "$review_network" >/dev/null
docker run -d --name "$review_db" --network "$review_network" --network-alias postgres \
--tmpfs /var/lib/postgresql/data \
-e POSTGRES_DB=discovery_review -e POSTGRES_USER=reviewer \
-e POSTGRES_PASSWORD=disposable-review-only postgres:16-alpine >/dev/null
review_ready=0
for _ in $(seq 1 40); do
if docker exec "$review_db" pg_isready -U reviewer -d discovery_review >/dev/null 2>&1; then review_ready=1; break; fi
sleep 0.5
done
[[ "$review_ready" == 1 ]]
run_backend() {
docker run --rm --network "$review_network" -v "$review_root:/review:ro" \
-w /review/backend -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/review/backend \
-e POSTGRES_HOST=postgres -e POSTGRES_DB=discovery_review -e POSTGRES_USER=reviewer \
-e POSTGRES_PASSWORD=disposable-review-only -e JWT_SECRET_KEY=disposable-review-only \
-e MINEROPS_DISCOVERY_DISPOSABLE=1 -e MINEROPS_HUB_MODE=true \
-e SCHEDULED_SCAN_ENABLED=false -e MINEROPS_WINDOWS_AGENT_SOURCE=/review/agents/windows \
"$MINEROPS_REVIEW_BACKEND_IMAGE" "$@"
}
run_backend alembic upgrade head
run_backend python -m unittest -v tests.test_discovery_security_postgres \
tests.test_agent_discovery_isolation_postgres \
tests.test_legacy_inventory_security_postgres
# Browser and PowerShell suites use their own intercepted/loopback transports.
# See docs/operations/BLOCKER_1_DISCOVERY_REVIEW.txt for exact commands and baseline exceptions.