Skip to content

Native Vulnerability & Misconfiguration Scanning (Trivy Integration) #97

Open

Description

@lordraiden

Summary

I propose integrating Trivy by Aqua Security natively into Compose Manager Plus. Trivy is a lightweight, open-source scanner that requires no cloud authentication and can scan both container images (for CVEs) and Compose files (for IaC misconfigurations).

Detailed description

As Unraid homelabs grow, security observability becomes a major blind spot. Users often run dozens of Docker Compose stacks without knowing if they are running outdated images with critical CVEs, or if their docker-compose.yml files contain severe misconfigurations (e.g., unnecessary privileged: true, exposed sensitive ports, or root user definitions). Currently, discovering this requires setting up complex third-party tools or running manual CLI commands.

Describe the solution you'd like:
I propose integrating Trivy by Aqua Security natively into Compose Manager Plus. Trivy is a lightweight, open-source scanner that requires no cloud authentication and scans both container images (for CVEs) and Compose files (for IaC misconfigurations).

By integrating this, Compose Manager Plus could become an all-in-one DevSecOps tool tailored for the Unraid community.

🖥️ Proposed Interface & UX Concept

The integration could add a "Security" dimension to the plugin, visualized in the following ways:

  1. At-A-Glance Badges: Add a small shield icon (🟢 Green, 🟡 Yellow, 🔴 Red) next to the stack names on the main Compose Manager page so users immediately know their security posture.
  2. Global Security Dashboard (The "Leaderboard"): A new tab featuring graphs (using Unraid's native styling or Chart.js) showing the total breakdown of vulnerabilities across all stacks. Include a "Hall of Shame" table that ranks stacks/images by their vulnerability score, allowing users to prioritize updates.
  3. Stack-Level Security Report: Inside individual project views, add a "Security" tab. Users can expand specific "Critical" vulnerabilities to see the exact package, CVE description, and crucially, the Fixed Version (so they know if pulling a new image will actually solve the problem).
  4. Unraid Native Notifications (Community Value-Add): Allow users to schedule weekly scans. If a new Critical vulnerability is detected in a public-facing container (like Nginx Proxy Manager or Nextcloud), trigger an Unraid system notification (which routes to the Unraid app/Discord/Telegram).

⚙️ High-Level Deployment Strategy (How to build it elegantly)

While users could run Trivy as a separate Docker container, that approach is clunky, requires manual socket mounts, and fractures the UI. Native plugin integration is much cleaner:

  • Installation via .plg: During plugin installation/update, the .plg script simply downloads the latest standalone Linux trivy binary into /usr/local/bin/. No daemon or dependencies are required.
  • Flash Drive Protection (Crucial for Unraid): Trivy downloads a vulnerability DB. To prevent wearing out the Unraid USB flash drive, the plugin must configure Trivy to cache this DB in RAM (/tmp/trivy-db) or allow the user to specify a path on their cache drive (e.g., /mnt/user/appdata/compose.manager/trivy).
  • Execution (Backend): When a user triggers a scan, PHP runs:
    • trivy image -f json <image_name> (for container CVEs)
    • trivy config -f json /path/to/stack/ (for Compose misconfigurations)
  • Parsing (Frontend): Because Trivy outputs clean JSON, the PHP backend can simply use json_decode() and pass the arrays directly to the frontend to render the graphs and tables.

Alternatives considered

  • Docker Scout: Focuses mostly on images, requires a Docker Hub account/authentication, and adds friction for privacy-conscious homelab users.
  • Separate Trivy Container: Forces the user to configure Docker socket mounts manually and jump between different web UIs, ruining the unified experience Compose Manager Plus provides.

Additional context:
Making security actionable and visible right inside the Unraid UI would make this plugin an absolute must-have for the community. Having a visual graph of CVEs and alerting users to misconfigurations would elevate the security standard of Unraid homelabs everywhere. I'd love to hear your thoughts on this!

Activity

  1. lordraiden commented on Apr 15, 2026

    @lordraiden
    Author

    Proposal: Native Trivy-Based Security Scanning in Compose Manager Plus

    Summary

    Compose Manager Plus should add native security scanning for stack images using Trivy.

    This is not feature creep. It is a direct extension of what the plugin already does well: it is the control plane for Docker Compose stacks on Unraid. It already knows what stacks exist, what images they use, what containers are running, and what the operational state is. The next logical step is to expose the security state of those same stacks.

    Right now, Compose Manager Plus answers:

    • what is deployed
    • what is running
    • what is outdated

    It should also answer:

    • what is vulnerable
    • which stacks carry the highest risk
    • what needs to be updated first

    That would move the plugin from being a convenience layer to being the default operational dashboard for Compose-based homelabs on Unraid.


    Problem

    As Unraid homelabs scale, users accumulate large numbers of Compose stacks and usually lose visibility into the security posture of the images those stacks depend on.

    At that point, security becomes reactive and fragmented:

    • users do not know which images contain Critical CVEs
    • users do not know which stacks are currently highest risk
    • users do not know whether pulling a newer image will actually remediate the issue
    • users are forced into manual CLI work or separate third-party tools

    This is exactly the kind of operational gap Compose Manager Plus is in a unique position to close.


    Why this belongs in Compose Manager Plus

    Compose Manager Plus already owns the correct abstraction layer: the stack.

    That matters because users do not think in terms of isolated images. They think in terms of:

    • “my Nextcloud stack”
    • “my reverse proxy stack”
    • “my monitoring stack”

    If security is exposed anywhere, it should be exposed at that same level.

    A separate Trivy container or external dashboard is possible, but it is the wrong user experience:

    • extra deployment burden
    • extra configuration
    • extra storage management
    • extra UI fragmentation
    • lower adoption

    If this ships natively, users will actually use it.


    Scope

    This proposal is intentionally opinionated:

    v1 should focus on image security scanning grouped by stack.

    Not Compose linting.
    Not full IaC policy evaluation.
    Not external dashboards.

    Just:

    1. resolve images from each stack
    2. scan them with Trivy
    3. aggregate findings back into the existing stack UI
    4. persist results
    5. allow manual and scheduled rescans
    6. notify only on meaningful change

    That is enough to deliver immediate value without overcomplicating the architecture.


    Why Trivy

    Trivy is the right fit because it is:

    • lightweight
    • local-first
    • well known
    • open source
    • no cloud auth required
    • easy to install as a standalone binary
    • produces clean JSON output
    • supports severity filtering, cache control, and optional image-config scanning

    That makes it suitable for native plugin integration rather than forcing a secondary containerized workflow.


    Product outcome

    If implemented correctly, Compose Manager Plus becomes the place where an Unraid user can answer all of the following in one interface:

    • Which stacks are running?
    • Which stacks are outdated?
    • Which stacks are vulnerable?
    • Which images are the worst offenders?
    • Which findings are actually fixable?
    • Did anything new appear since the last scan?

    That is a major jump in practical value.


    Implementation plan

    1. Core design decision

    The scanner should operate on resolved stack images, not just currently running containers.

    That is the correct design because it allows:

    • scanning stopped stacks
    • deduplicating shared images across stacks
    • keeping security tied to the Compose project model
    • avoiding blind spots caused by runtime-only inspection

    The stack remains the primary UI grouping, but the scan unit becomes the unique image.

    That gives the plugin the best of both worlds:

    • stack-centric UX
    • image-centric scanning efficiency

    2. Proposed file additions

    source/compose.manager/php/security_trivy.php
    source/compose.manager/php/security_scan_runner.php
    source/compose.manager/php/security_helpers.php
    source/compose.manager/javascript/security.js
    source/compose.manager/styles/security.css
    

    Extend existing files:

    source/compose.manager/php/exec.php
    source/compose.manager/default.cfg
    source/compose.manager/php/compose_list.php
    source/compose.manager/php/compose_manager_main.php
    source/compose.manager/compose.manager.settings.page
    compose.manager.plg
    

    3. Settings

    Add the following to default.cfg:

    SECURITY_ENABLED="false"
    SECURITY_TRIVY_BIN="/usr/local/bin/trivy"
    SECURITY_CACHE_DIR="/mnt/user/appdata/compose.manager/trivy"
    SECURITY_SCAN_SCHEDULE="weekly"
    SECURITY_SCAN_DAY="0"
    SECURITY_SCAN_TIME="03:30"
    SECURITY_MIN_SEVERITY="HIGH"
    SECURITY_INCLUDE_UNFIXED="true"
    SECURITY_SCAN_IMAGE_CONFIG="false"
    SECURITY_NOTIFY_ON_NEW_CRITICAL="true"
    SECURITY_NOTIFY_ON_NEW_HIGH="false"
    SECURITY_STATUS_FILE="/boot/config/plugins/compose.manager/security-status.json"
    SECURITY_HISTORY_FILE="/boot/config/plugins/compose.manager/security-history.json"

    Storage strategy

    • keep small plugin metadata in /boot/config/plugins/...
    • keep Trivy DB/cache in /mnt/user/appdata/... or /tmp/...
    • do not place the Trivy vulnerability DB on the flash drive

    This is not optional. Writing the Trivy cache/DB to flash would be the wrong default for Unraid.


    4. Stack image resolution

    The plugin should resolve images directly from the Compose stack context.

    Example approach:

    <?php
    function getStackImagesFromCompose(StackInfo $stackInfo): array
    {
        $cmd = $stackInfo->buildComposeCommand([
            'config',
            '--images',
        ]);
    
        $output = [];
        $rc = 0;
        exec($cmd . ' 2>&1', $output, $rc);
    
        if ($rc !== 0) {
            throw new RuntimeException(
                "Failed to resolve images for stack {$stackInfo->projectFolder}: " . implode("\n", $output)
            );
        }
    
        $images = array_values(array_unique(array_filter(array_map('trim', $output))));
        sort($images);
        return $images;
    }

    This should be the canonical source of image membership for a stack.

    Do not rely only on running containers. That would miss stopped stacks and make the feature feel inconsistent.


    5. Unique-image scan planning

    Once images are resolved per stack, the plugin should build a global unique scan plan.

    <?php
    function buildGlobalImageScanPlan(array $stacks): array
    {
        $plan = []; // image => [stackA, stackB]
    
        foreach ($stacks as $stackInfo) {
            foreach (getStackImagesFromCompose($stackInfo) as $image) {
                $plan[$image] ??= [];
                $plan[$image][] = $stackInfo->projectFolder;
            }
        }
    
        ksort($plan);
        return $plan;
    }

    This matters because shared images are common across homelabs:

    • nginx
    • redis
    • postgres
    • alpine-based sidecars
    • generic Linuxserver images

    Scanning each unique image once is the obvious architecture.


    6. Trivy execution layer

    The backend should wrap Trivy as a local binary and normalize its output.

    <?php
    final class TrivyScanner
    {
        public function __construct(
            private string $trivyBin,
            private string $cacheDir,
            private bool $includeUnfixed = true,
            private bool $scanImageConfig = false,
            private string $minSeverity = 'HIGH',
        ) {}
    
        public function scanImage(string $image): array
        {
            $tmpFile = tempnam('/tmp', 'trivy_');
            if ($tmpFile === false) {
                throw new RuntimeException('Failed to create temp file for Trivy output');
            }
    
            $args = [
                escapeshellarg($this->trivyBin),
                'image',
                '--quiet',
                '--format', 'json',
                '--output', escapeshellarg($tmpFile),
                '--cache-dir', escapeshellarg($this->cacheDir),
                '--severity', escapeshellarg($this->normalizeSeverity($this->minSeverity)),
                '--timeout', '5m',
            ];
    
            if (!$this->includeUnfixed) {
                $args[] = '--ignore-unfixed';
            }
    
            if ($this->scanImageConfig) {
                $args[] = '--image-config-scanners';
                $args[] = 'misconfig,secret';
            }
    
            $args[] = escapeshellarg($image);
    
            $cmd = implode(' ', $args) . ' 2>&1';
            $output = [];
            $rc = 0;
            exec($cmd, $output, $rc);
    
            $raw = @file_get_contents($tmpFile);
            @unlink($tmpFile);
    
            if ($rc !== 0 || $raw === false || trim($raw) === '') {
                throw new RuntimeException(
                    "Trivy scan failed for {$image}: exit={$rc}; output=" . implode("\n", $output)
                );
            }
    
            $decoded = json_decode($raw, true);
            if (!is_array($decoded)) {
                throw new RuntimeException("Invalid Trivy JSON for {$image}");
            }
    
            return $decoded;
        }
    
        private function normalizeSeverity(string $severity): string
        {
            $severity = strtoupper(trim($severity));
            return match ($severity) {
                'CRITICAL' => 'CRITICAL',
                'HIGH' => 'HIGH,CRITICAL',
                'MEDIUM' => 'MEDIUM,HIGH,CRITICAL',
                'LOW' => 'LOW,MEDIUM,HIGH,CRITICAL',
                default => 'HIGH,CRITICAL',
            };
        }
    }

    The plugin should treat Trivy as an engine, not as a UI source of truth. All frontend-facing data should be normalized first.


    7. Normalized result model

    The raw Trivy JSON should be transformed into a plugin-specific schema.

    <?php
    function summarizeTrivyReport(string $image, array $report): array
    {
        $summary = [
            'image' => $image,
            'generatedAt' => time(),
            'counts' => [
                'CRITICAL' => 0,
                'HIGH' => 0,
                'MEDIUM' => 0,
                'LOW' => 0,
                'UNKNOWN' => 0,
            ],
            'vulnerabilities' => [],
            'misconfigurations' => [],
            'secrets' => [],
        ];
    
        foreach (($report['Results'] ?? []) as $result) {
            foreach (($result['Vulnerabilities'] ?? []) as $vuln) {
                $severity = strtoupper($vuln['Severity'] ?? 'UNKNOWN');
                if (!isset($summary['counts'][$severity])) {
                    $summary['counts'][$severity] = 0;
                }
                $summary['counts'][$severity]++;
    
                $summary['vulnerabilities'][] = [
                    'target' => $result['Target'] ?? '',
                    'class' => $result['Class'] ?? '',
                    'type' => $result['Type'] ?? '',
                    'id' => $vuln['VulnerabilityID'] ?? '',
                    'pkgName' => $vuln['PkgName'] ?? '',
                    'installedVersion' => $vuln['InstalledVersion'] ?? '',
                    'fixedVersion' => $vuln['FixedVersion'] ?? '',
                    'severity' => $severity,
                    'title' => $vuln['Title'] ?? '',
                    'primaryUrl' => $vuln['PrimaryURL'] ?? '',
                    'description' => $vuln['Description'] ?? '',
                ];
            }
    
            foreach (($result['Misconfigurations'] ?? []) as $misconfig) {
                $summary['misconfigurations'][] = [
                    'id' => $misconfig['ID'] ?? '',
                    'title' => $misconfig['Title'] ?? '',
                    'severity' => strtoupper($misconfig['Severity'] ?? 'UNKNOWN'),
                    'message' => $misconfig['Message'] ?? '',
                    'resolution' => $misconfig['Resolution'] ?? '',
                ];
            }
    
            foreach (($result['Secrets'] ?? []) as $secret) {
                $summary['secrets'][] = [
                    'ruleId' => $secret['RuleID'] ?? '',
                    'category' => $secret['Category'] ?? '',
                    'severity' => strtoupper($secret['Severity'] ?? 'UNKNOWN'),
                    'title' => $secret['Title'] ?? '',
                    'startLine' => $secret['StartLine'] ?? null,
                    'endLine' => $secret['EndLine'] ?? null,
                ];
            }
        }
    
        usort($summary['vulnerabilities'], function ($a, $b) {
            $rank = ['CRITICAL' => 4, 'HIGH' => 3, 'MEDIUM' => 2, 'LOW' => 1, 'UNKNOWN' => 0];
            return ($rank[$b['severity']] ?? 0) <=> ($rank[$a['severity']] ?? 0);
        });
    
        return $summary;
    }

    This is the correct place to flatten, sort, and trim the Trivy output so the frontend does not need to understand Trivy internals.


    8. Persisted status model

    Security should be persisted the same way update-state is persisted: lightweight, file-backed, and easy to hydrate into the UI.

    Suggested shape:

    {
      "generatedAt": 1776218400,
      "stacks": {
        "nextcloud": {
          "badge": "red",
          "score": 92,
          "summary": {
            "critical": 3,
            "high": 11,
            "medium": 28,
            "low": 14
          },
          "images": {
            "nextcloud:29-apache": {
              "critical": 2,
              "high": 7,
              "medium": 18,
              "low": 9,
              "lastScanned": 1776218400
            },
            "redis:7-alpine": {
              "critical": 1,
              "high": 4,
              "medium": 10,
              "low": 5,
              "lastScanned": 1776218400
            }
          }
        }
      },
      "images": {
        "nextcloud:29-apache": {
          "counts": {
            "CRITICAL": 2,
            "HIGH": 7,
            "MEDIUM": 18,
            "LOW": 9,
            "UNKNOWN": 0
          },
          "vulnerabilities": []
        }
      }
    }

    This gives the plugin:

    • fast reloads
    • historical diffing
    • dashboard generation
    • per-stack badge hydration

    9. AJAX actions

    This should be wired into exec.php using the same pattern as other plugin actions.

    case 'scanStackSecurity':
        $script = getPostScript();
        if (!$script) {
            echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']);
            break;
        }
    
        require_once('/usr/local/emhttp/plugins/compose.manager/php/security_trivy.php');
    
        try {
            $stackInfo = StackInfo::fromProject($compose_root, $script);
            $result = scanSingleStackSecurity($stackInfo, parse_plugin_cfg('compose.manager'));
            echo json_encode(['result' => 'success', 'security' => $result]);
        } catch (\Throwable $e) {
            clientDebug('[security] scanStackSecurity failed: ' . $e->getMessage(), null, 'daemon', 'error');
            echo json_encode(['result' => 'error', 'message' => $e->getMessage()]);
        }
        break;
    
    case 'scanAllStacksSecurity':
        require_once('/usr/local/emhttp/plugins/compose.manager/php/security_trivy.php');
    
        try {
            $result = scanAllStacksSecurity(StackInfo::allFromRoot($compose_root), parse_plugin_cfg('compose.manager'));
            echo json_encode(['result' => 'success', 'security' => $result]);
        } catch (\Throwable $e) {
            clientDebug('[security] scanAllStacksSecurity failed: ' . $e->getMessage(), null, 'daemon', 'error');
            echo json_encode(['result' => 'error', 'message' => $e->getMessage()]);
        }
        break;
    
    case 'getSavedSecurityStatus':
        $file = parse_plugin_cfg('compose.manager')['SECURITY_STATUS_FILE']
            ?? '/boot/config/plugins/compose.manager/security-status.json';
    
        if (!is_file($file)) {
            echo json_encode(['result' => 'success', 'security' => ['stacks' => [], 'images' => []]]);
            break;
        }
    
        $decoded = json_decode(file_get_contents($file), true) ?: ['stacks' => [], 'images' => []];
        echo json_encode(['result' => 'success', 'security' => $decoded]);
        break;

    This is the right integration point because it preserves the plugin’s current request/response model.


    10. Badge model

    Do not over-engineer this. Users need immediate clarity.

    <?php
    function calculateSecurityBadge(array $counts): string
    {
        $critical = (int)($counts['CRITICAL'] ?? 0);
        $high = (int)($counts['HIGH'] ?? 0);
    
        if ($critical > 0) return 'red';
        if ($high > 0) return 'yellow';
        return 'green';
    }
    
    function calculateSecurityScore(array $counts): int
    {
        $critical = (int)($counts['CRITICAL'] ?? 0);
        $high = (int)($counts['HIGH'] ?? 0);
        $medium = (int)($counts['MEDIUM'] ?? 0);
    
        $penalty = ($critical * 20) + ($high * 5) + ($medium * 1);
        return max(0, 100 - $penalty);
    }

    Badge semantics

    • red = one or more Critical findings
    • yellow = no Critical, but one or more High findings
    • green = no High/Critical
    • grey = never scanned / unknown

    That is enough.


    11. Main stack list integration

    Security should be visible on the main page immediately.

    Example addition in compose_list.php:

    <?php
    $security = $savedSecurity['stacks'][$stackInfo->projectFolder] ?? null;
    $securityBadge = $security['badge'] ?? 'unknown';
    $securitySummary = $security['summary'] ?? ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0];
    
    $securityHtml = sprintf(
        '<span class="compose-security-badge security-%s" 
            title="Security: %d critical, %d high, %d medium, %d low">
            <i class="fa fa-shield-alt"></i>
         </span>',
        htmlspecialchars($securityBadge, ENT_QUOTES, 'UTF-8'),
        (int)$securitySummary['critical'],
        (int)$securitySummary['high'],
        (int)$securitySummary['medium'],
        (int)$securitySummary['low']
    );

    CSS:

    .compose-security-badge {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      margin-left: 8px;
      width: 18px;
      height: 18px;
      border-radius: 50%;
      font-size: 11px;
    }
    
    .compose-security-badge.security-green  { background: #2d9d58; color: #fff; }
    .compose-security-badge.security-yellow { background: #c58a00; color: #fff; }
    .compose-security-badge.security-red    { background: #c43c3c; color: #fff; }
    .compose-security-badge.security-unknown{ background: #6c757d; color: #fff; }

    If the feature ships but users have to click three levels deep to see it, adoption will be weaker. The main stack list is the correct place for the first signal.


    12. Frontend hydration

    The saved security state should hydrate into the UI the same way update status already does.

    function loadSavedSecurityStatus() {
      return $.post(caURL, { action: 'getSavedSecurityStatus' }, function (resp) {
        if (typeof resp === 'string') {
          try { resp = JSON.parse(resp); } catch (e) { return; }
        }
        if (!resp || resp.result !== 'success' || !resp.security || !resp.security.stacks) return;
    
        Object.keys(resp.security.stacks).forEach(function (stackName) {
          updateStackSecurityUI(stackName, resp.security.stacks[stackName]);
        });
      });
    }
    
    function updateStackSecurityUI(stackName, sec) {
      var $row = $('#compose_stacks tr.compose-sortable[data-project="' + stackName + '"]');
      if (!$row.length) return;
    
      var $badge = $row.find('.compose-security-badge').first();
      if (!$badge.length) return;
    
      $badge
        .removeClass('security-green security-yellow security-red security-unknown')
        .addClass('security-' + (sec.badge || 'unknown'))
        .attr(
          'title',
          'Security: ' +
          (sec.summary?.critical || 0) + ' critical, ' +
          (sec.summary?.high || 0) + ' high, ' +
          (sec.summary?.medium || 0) + ' medium, ' +
          (sec.summary?.low || 0) + ' low'
        );
    }

    This should be automatic on page load. Not a secondary action.


    13. Stack-level Security tab

    Each stack should get a dedicated Security tab with three sections.

    Summary

    • badge
    • score
    • total counts by severity
    • last scan timestamp

    Images

    • service name
    • image name
    • per-image counts
    • optionally running/stopped context

    Findings

    • severity
    • CVE ID
    • package name
    • installed version
    • fixed version
    • short title/description

    Example findings table:

    <?php foreach ($findings as $v): ?>
    <tr class="security-row severity-<?= strtolower($v['severity']) ?>">
      <td><?= htmlspecialchars($v['severity']) ?></td>
      <td>
        <a href="<?= htmlspecialchars($v['primaryUrl']) ?>" target="_blank" rel="noreferrer">
          <?= htmlspecialchars($v['id']) ?>
        </a>
      </td>
      <td><?= htmlspecialchars($v['pkgName']) ?></td>
      <td><?= htmlspecialchars($v['installedVersion']) ?></td>
      <td><?= htmlspecialchars($v['fixedVersion'] ?: '—') ?></td>
      <td><?= htmlspecialchars($v['title']) ?></td>
    </tr>
    <?php endforeach; ?>

    The key operational field here is fixed version. Without that, the UI is informative but not actionable.


    14. Global dashboard

    The plugin should also expose a global security dashboard built entirely from the persisted status JSON.

    Suggested widgets:

    • total findings by severity
    • top 10 riskiest stacks
    • top 10 riskiest images
    • newly introduced findings since previous scan
    • scan freshness / last updated timestamp

    Simple server-side shape:

    <?php
    $dashboard = [
        'severityTotals' => [
            'critical' => 0,
            'high' => 0,
            'medium' => 0,
            'low' => 0,
        ],
        'topStacks' => [],
        'topImages' => [],
    ];

    This can start as plain tables and counters. Fancy charts can wait.


    15. Scheduled scans

    A scheduled security workflow is necessary, but it must be disciplined.

    The job should:

    1. acquire a lock
    2. resolve stack images
    3. build the unique-image plan
    4. scan all unique images
    5. update security-status.json
    6. compare with previous state
    7. notify only on meaningful additions
    8. release the lock

    Example:

    <?php
    function withSecurityLock(callable $fn)
    {
        $lockFile = '/var/run/compose.manager.security.lock';
        $fp = fopen($lockFile, 'c');
        if (!$fp) {
            throw new RuntimeException('Unable to open security lock file');
        }
    
        if (!flock($fp, LOCK_EX | LOCK_NB)) {
            fclose($fp);
            throw new RuntimeException('Security scan already in progress');
        }
    
        try {
            return $fn();
        } finally {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    Overlapping scans should not happen.


    16. Notifications

    Notifications should be diff-based, not state-based.

    Users do not need a weekly reminder that the same known issue still exists. They need to know when:

    • a new Critical appears
    • a new High appears, if configured
    • a previously clean stack becomes risky

    Example diff logic:

    <?php
    function diffNewCriticals(array $old, array $new): array
    {
        $oldIds = [];
        foreach (($old['vulnerabilities'] ?? []) as $v) {
            if (($v['severity'] ?? '') === 'CRITICAL') {
                $oldIds[$v['id'] . '|' . $v['pkgName']] = true;
            }
        }
    
        $added = [];
        foreach (($new['vulnerabilities'] ?? []) as $v) {
            if (($v['severity'] ?? '') !== 'CRITICAL') continue;
    
            $key = ($v['id'] ?? '') . '|' . ($v['pkgName'] ?? '');
            if (!isset($oldIds[$key])) {
                $added[] = $v;
            }
        }
    
        return $added;
    }

    That keeps notifications useful instead of turning them into background noise.


    17. Plugin installation flow

    The .plg should install Trivy as a standalone binary.

    TRIVY_BIN="/usr/local/bin/trivy"
    TRIVY_VERSION="v0.69.3"
    ARCH="$(uname -m)"
    
    case "$ARCH" in
      x86_64) TRIVY_ARCH="64bit" ;;
      aarch64) TRIVY_ARCH="ARM64" ;;
      *) echo "Unsupported arch: $ARCH"; exit 1 ;;
    esac
    
    TMP_DIR="$(mktemp -d)"
    cd "$TMP_DIR" || exit 1
    
    curl -L -o trivy.tar.gz \
      "https://github.com/aquasecurity/trivy/releases/download/${TRIVY_VERSION}/trivy_${TRIVY_VERSION#v}_Linux-${TRIVY_ARCH}.tar.gz"
    
    tar -xzf trivy.tar.gz
    install -m 0755 trivy "$TRIVY_BIN"
    rm -rf "$TMP_DIR"

    This is the correct operational model:

    • no sidecar container
    • no extra daemon
    • no separate web UI
    • no socket mount gymnastics

    Rollout recommendation

    Phase 1

    • install Trivy via .plg
    • manual per-stack scan
    • scan-all
    • persisted status JSON
    • stack list badge
    • stack Security tab

    Phase 2

    • scheduled scans
    • dashboard
    • diff-based notifications
    • risk ranking

    Phase 3

    • optional image-config scanning
    • report export
    • custom Compose checks if desired

    This sequence delivers value early and avoids blocking on secondary features.


    Alternatives considered

    Separate Trivy container

    Rejected as the primary solution.

    It works technically, but it is the wrong UX:

    • more setup
    • more mounts
    • more persistence management
    • separate interface
    • weaker adoption

    Docker Scout

    Useful in general, but not the best default for this use case:

    • additional auth/account expectations
    • less local-first
    • less aligned with privacy-conscious homelab users
    • more friction than necessary

    Final position

    This should not be treated as a “nice to have.”

    For the kind of users Compose Manager Plus targets, this is a natural and high-value capability:

    • the plugin already manages stacks
    • the plugin already understands images
    • the plugin already surfaces operational state
    • the missing dimension is security

    Adding native Trivy-based image scanning would make Compose Manager Plus not just a Compose UI, but the central operational and security console for Docker Compose on Unraid.

    That is a meaningful product upgrade, not just another checkbox feature.

  2. mstrhakr commented on Apr 15, 2026

    @mstrhakr
    Owner

    I see a thumbs down on this but I'm curious. This may be better suited to a new plugin that handles docker and compose in one plugin. I do like the idea of some kind of security scan/warnings for common issues in compose files though. This will likely not be implemented right away though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions