Skip to content

Commit 409ca43

Browse files
committed
feat(039): scan context, file tree API, scan history with limits
ScanContext on ScanJob with source method, path, isolation status, file list. File tree API with suspicious file markers. Auto-prune keeps last 20 scans. Frontend scan context banner + scanned files collapsible with lazy loading. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 31adfd5 commit 409ca43

31 files changed

Lines changed: 607 additions & 38 deletions

frontend/src/services/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,10 @@ class APIService {
811811
return this.request<any>(`/api/v1/servers/${encodeURIComponent(serverName)}/scan/report`)
812812
}
813813

814+
async getScanFiles(serverName: string): Promise<APIResponse<any>> {
815+
return this.request<any>(`/api/v1/servers/${encodeURIComponent(serverName)}/scan/files`)
816+
}
817+
814818
async cancelScan(serverName: string): Promise<APIResponse<void>> {
815819
return this.request<void>(`/api/v1/servers/${encodeURIComponent(serverName)}/scan/cancel`, {
816820
method: 'POST',

frontend/src/views/ServerDetail.vue

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,75 @@
531531
</div>
532532
</div>
533533

534+
<!-- Scan Context Banner -->
535+
<div v-if="scanContext" class="mt-2">
536+
<!-- No Docker Isolation (local process) -->
537+
<div v-if="!scanContext.docker_isolation && scanContext.source_method !== 'url' && scanContext.source_method !== 'none'" class="alert alert-warning">
538+
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
539+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
540+
</svg>
541+
<div>
542+
<h3 class="font-bold">No Docker Isolation</h3>
543+
<p class="text-sm">This server runs locally without Docker isolation.</p>
544+
<p class="text-sm">
545+
Source: <code class="bg-base-300 px-1 rounded text-xs">{{ scanContext.source_path }}</code>
546+
<span v-if="scanContext.total_files"> ({{ scanContext.total_files }} files, {{ formatFileSize(scanContext.total_size_bytes) }})</span>
547+
</p>
548+
<p class="text-sm text-base-content/70">
549+
Protocol: {{ scanContext.server_protocol }}
550+
<span v-if="scanContext.server_command"> &bull; Command: {{ scanContext.server_command }}</span>
551+
</p>
552+
</div>
553+
</div>
554+
555+
<!-- Docker Isolated -->
556+
<div v-else-if="scanContext.docker_isolation" class="alert alert-info">
557+
<svg class="w-6 h-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
558+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
559+
</svg>
560+
<div>
561+
<h3 class="font-bold">Docker Isolated</h3>
562+
<p class="text-sm">
563+
Source extracted from container<span v-if="scanContext.container_id">: <code class="bg-base-300 px-1 rounded text-xs">{{ scanContext.container_id.substring(0, 12) }}...</code></span>
564+
</p>
565+
<p class="text-sm">
566+
Source: <code class="bg-base-300 px-1 rounded text-xs">{{ scanContext.source_path }}</code>
567+
<span v-if="scanContext.total_files"> ({{ scanContext.total_files }} files, {{ formatFileSize(scanContext.total_size_bytes) }})</span>
568+
</p>
569+
<p class="text-sm text-base-content/70">
570+
Protocol: {{ scanContext.server_protocol }}
571+
<span v-if="scanContext.server_command"> &bull; Command: {{ scanContext.server_command }}</span>
572+
</p>
573+
</div>
574+
</div>
575+
576+
<!-- HTTP Server (url) -->
577+
<div v-else-if="scanContext.source_method === 'url'" class="alert">
578+
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
579+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
580+
</svg>
581+
<div>
582+
<h3 class="font-bold">HTTP Server</h3>
583+
<p class="text-sm">Behavioral scanning only (no filesystem to scan)</p>
584+
<p class="text-sm">
585+
URL: <code class="bg-base-300 px-1 rounded text-xs">{{ scanContext.source_path }}</code>
586+
</p>
587+
</div>
588+
</div>
589+
590+
<!-- No Source Available -->
591+
<div v-else-if="scanContext.source_method === 'none'" class="alert alert-error">
592+
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
593+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
594+
</svg>
595+
<div>
596+
<h3 class="font-bold">No Source Available</h3>
597+
<p class="text-sm">Could not resolve source files for scanning.</p>
598+
<p class="text-sm text-base-content/70">Server may be disconnected or not running in Docker.</p>
599+
</div>
600+
</div>
601+
</div>
602+
534603
<!-- Scan error -->
535604
<div v-if="scanError" class="alert alert-error">
536605
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -748,6 +817,47 @@
748817
</div>
749818
</div>
750819
</div>
820+
821+
<!-- Scanned Files (lazy-loaded collapsible) -->
822+
<div v-if="scanContext && scanContext.source_method !== 'none' && scanContext.source_method !== 'url'" class="pt-4">
823+
<div class="collapse collapse-arrow bg-base-100 shadow-md">
824+
<input type="checkbox" @change="onScannedFilesToggle" />
825+
<div class="collapse-title font-medium">
826+
Scanned Files
827+
<span v-if="scanContext.total_files" class="text-base-content/60 font-normal">
828+
({{ scanContext.total_files }} files, {{ formatFileSize(scanContext.total_size_bytes) }})
829+
</span>
830+
</div>
831+
<div class="collapse-content">
832+
<div v-if="scanFilesLoading" class="text-center py-4">
833+
<span class="loading loading-spinner loading-sm"></span>
834+
<span class="ml-2 text-sm">Loading file list...</span>
835+
</div>
836+
<div v-else-if="scanFiles.length === 0" class="text-sm text-base-content/40 py-2">
837+
No file information available.
838+
</div>
839+
<ul v-else class="space-y-0.5 py-1">
840+
<li
841+
v-for="(file, idx) in scanFiles"
842+
:key="file.path"
843+
class="flex items-center gap-2 py-0.5"
844+
>
845+
<span class="text-base-content/30 text-xs select-none w-4 text-right">{{ idx === scanFiles.length - 1 ? '\u2514' : '\u251C' }}</span>
846+
<code
847+
class="text-sm"
848+
:class="file.suspicious ? 'text-error font-semibold' : 'text-base-content/80'"
849+
>{{ file.path }}</code>
850+
<span
851+
v-if="file.suspicious && file.findings?.length"
852+
class="badge badge-error badge-xs gap-1"
853+
>
854+
{{ file.findings.join(', ') }}
855+
</span>
856+
</li>
857+
</ul>
858+
</div>
859+
</div>
860+
</div>
751861
</template>
752862
</div>
753863
</div>
@@ -824,6 +934,15 @@ const scanError = ref<string | null>(null)
824934
const securityActionLoading = ref(false)
825935
let scanPollTimer: ReturnType<typeof setInterval> | null = null
826936
937+
// Scan context & files
938+
const scanFiles = ref<Array<{ path: string; suspicious: boolean; findings?: string[] }>>([])
939+
const scanFilesLoading = ref(false)
940+
const scanFilesLoaded = ref(false)
941+
942+
const scanContext = computed(() => {
943+
return scanStatus.value?.scan_context || null
944+
})
945+
827946
// Logs
828947
const serverLogs = ref<string[]>([])
829948
const logsLoading = ref(false)
@@ -1341,6 +1460,31 @@ function viewToolSchema(tool: Tool) {
13411460
}
13421461
13431462
// Security scan functions (Spec 039)
1463+
function formatFileSize(bytes: number): string {
1464+
if (!bytes || bytes === 0) return '0 B'
1465+
if (bytes < 1024) return `${bytes} B`
1466+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
1467+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
1468+
}
1469+
1470+
async function onScannedFilesToggle(event: Event) {
1471+
const checkbox = event.target as HTMLInputElement
1472+
if (checkbox.checked && !scanFilesLoaded.value && server.value) {
1473+
scanFilesLoading.value = true
1474+
try {
1475+
const response = await api.getScanFiles(server.value.name)
1476+
if (response.success && response.data) {
1477+
scanFiles.value = response.data.files || []
1478+
scanFilesLoaded.value = true
1479+
}
1480+
} catch {
1481+
// Silently fail
1482+
} finally {
1483+
scanFilesLoading.value = false
1484+
}
1485+
}
1486+
}
1487+
13441488
function formatRelativeTime(isoString: string): string {
13451489
const date = new Date(isoString)
13461490
const now = new Date()
@@ -1391,6 +1535,8 @@ async function startSecurityScan() {
13911535
13921536
scanLoading.value = true
13931537
scanError.value = null
1538+
scanFiles.value = []
1539+
scanFilesLoaded.value = false
13941540
13951541
try {
13961542
const response = await api.startScan(server.value.name)

internal/httpapi/security_scanner.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,108 @@ func (s *Server) handleSecurityOverview(w http.ResponseWriter, r *http.Request)
303303
}
304304
s.writeSuccess(w, overview)
305305
}
306+
307+
func (s *Server) handleGetScanFiles(w http.ResponseWriter, r *http.Request) {
308+
if !s.requireSecurity(w, r) {
309+
return
310+
}
311+
name := chi.URLParam(r, "id")
312+
if name == "" {
313+
s.writeError(w, r, http.StatusBadRequest, "server name is required")
314+
return
315+
}
316+
317+
// Get scan status for context
318+
job, err := s.securityController.GetScanStatus(r.Context(), name)
319+
if err != nil {
320+
s.writeError(w, r, http.StatusNotFound, err.Error())
321+
return
322+
}
323+
324+
// Get report for finding locations
325+
report, _ := s.securityController.GetScanReport(r.Context(), name)
326+
327+
// Build file tree with suspicious markers
328+
result := buildFileTree(job, report)
329+
s.writeSuccess(w, result)
330+
}
331+
332+
// fileTreeEntry represents a file in the scanned directory tree
333+
type fileTreeEntry struct {
334+
Path string `json:"path"`
335+
Suspicious bool `json:"suspicious"`
336+
Findings []string `json:"findings,omitempty"` // Finding titles for this file
337+
}
338+
339+
type fileTreeResponse struct {
340+
SourceMethod string `json:"source_method"`
341+
SourcePath string `json:"source_path"`
342+
DockerIsolation bool `json:"docker_isolation"`
343+
TotalFiles int `json:"total_files"`
344+
TotalSizeBytes int64 `json:"total_size_bytes"`
345+
Files []fileTreeEntry `json:"files"`
346+
}
347+
348+
func buildFileTree(job *scanner.ScanJob, report *scanner.AggregatedReport) *fileTreeResponse {
349+
resp := &fileTreeResponse{}
350+
351+
if job == nil || job.ScanContext == nil {
352+
return resp
353+
}
354+
355+
ctx := job.ScanContext
356+
resp.SourceMethod = ctx.SourceMethod
357+
resp.SourcePath = ctx.SourcePath
358+
resp.DockerIsolation = ctx.DockerIsolation
359+
resp.TotalFiles = ctx.TotalFiles
360+
resp.TotalSizeBytes = ctx.TotalSizeBytes
361+
362+
// Build location-to-findings lookup from report
363+
locationFindings := make(map[string][]string)
364+
if report != nil {
365+
for _, f := range report.Findings {
366+
if f.Location != "" {
367+
// Normalize: strip line number for matching
368+
filePath := f.Location
369+
if idx := lastIndexByte(filePath, ':'); idx > 0 {
370+
filePath = filePath[:idx]
371+
}
372+
// Strip leading /scan/source/ prefix
373+
filePath = trimScanPrefix(filePath)
374+
locationFindings[filePath] = append(locationFindings[filePath], f.Title)
375+
}
376+
}
377+
}
378+
379+
// Build entries
380+
for _, path := range ctx.ScannedFiles {
381+
normalized := trimScanPrefix(path)
382+
entry := fileTreeEntry{Path: path}
383+
if findings, ok := locationFindings[normalized]; ok {
384+
entry.Suspicious = true
385+
entry.Findings = findings
386+
}
387+
resp.Files = append(resp.Files, entry)
388+
}
389+
390+
return resp
391+
}
392+
393+
func lastIndexByte(s string, c byte) int {
394+
for i := len(s) - 1; i >= 0; i-- {
395+
if s[i] == c {
396+
return i
397+
}
398+
}
399+
return -1
400+
}
401+
402+
func trimScanPrefix(path string) string {
403+
prefixes := []string{"/scan/source/", "scan/source/", "/src/"}
404+
for _, p := range prefixes {
405+
if len(path) > len(p) && path[:len(p)] == p {
406+
return path[len(p):]
407+
}
408+
}
409+
return path
410+
}

internal/httpapi/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ func (s *Server) setupRoutes() {
505505
r.Get("/scan/status", s.handleGetScanStatus)
506506
r.Get("/scan/report", s.handleGetScanReport)
507507
r.Post("/scan/cancel", s.handleCancelScan)
508+
r.Get("/scan/files", s.handleGetScanFiles)
508509
r.Post("/security/approve", s.handleSecurityApprove)
509510
r.Post("/security/reject", s.handleSecurityReject)
510511
r.Get("/integrity", s.handleCheckIntegrity)

internal/security/scanner/engine.go

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,12 @@ func NewEngine(docker *DockerRunner, registry *Registry, dataDir string, logger
4040

4141
// ScanRequest describes a scan to execute
4242
type ScanRequest struct {
43-
ServerName string
44-
SourceDir string // Path to server source files (for "source" input)
45-
DryRun bool // If true, don't affect quarantine state
46-
ScannerIDs []string // Specific scanners to use (empty = all installed)
47-
Env map[string]string // Additional environment variables
43+
ServerName string
44+
SourceDir string // Path to server source files (for "source" input)
45+
DryRun bool // If true, don't affect quarantine state
46+
ScannerIDs []string // Specific scanners to use (empty = all installed)
47+
Env map[string]string // Additional environment variables
48+
ScanContext *ScanContext // Context metadata (set by service)
4849
}
4950

5051
// ScanCallback receives scan lifecycle events
@@ -95,12 +96,13 @@ func (e *Engine) StartScan(ctx context.Context, req ScanRequest, callback ScanCa
9596

9697
// Create job
9798
job := &ScanJob{
98-
ID: fmt.Sprintf("scan-%s-%d", req.ServerName, time.Now().UnixNano()),
99-
ServerName: req.ServerName,
100-
Status: ScanJobStatusRunning,
101-
Scanners: make([]string, len(scanners)),
102-
StartedAt: time.Now(),
103-
DryRun: req.DryRun,
99+
ID: fmt.Sprintf("scan-%s-%d", req.ServerName, time.Now().UnixNano()),
100+
ServerName: req.ServerName,
101+
Status: ScanJobStatusRunning,
102+
Scanners: make([]string, len(scanners)),
103+
StartedAt: time.Now(),
104+
DryRun: req.DryRun,
105+
ScanContext: req.ScanContext,
104106
}
105107
for i, s := range scanners {
106108
job.Scanners[i] = s.ID
@@ -411,8 +413,8 @@ func (e *Engine) setScannerLogs(job *ScanJob, scannerID string, logs scannerLogs
411413
defer e.mu.Unlock()
412414
for i := range job.ScannerStatuses {
413415
if job.ScannerStatuses[i].ScannerID == scannerID {
414-
job.ScannerStatuses[i].Stdout = truncate(logs.Stdout, 50000)
415-
job.ScannerStatuses[i].Stderr = truncate(logs.Stderr, 50000)
416+
job.ScannerStatuses[i].Stdout = truncate(logs.Stdout, MaxLogBytes)
417+
job.ScannerStatuses[i].Stderr = truncate(logs.Stderr, MaxLogBytes)
416418
job.ScannerStatuses[i].ExitCode = logs.ExitCode
417419
return
418420
}

0 commit comments

Comments
 (0)