Files
Evan Jarrett 6758996300 add SBOM package diffing, verify hold-service captain records
- diff view gains a Packages tab with added/removed/changed/unchanged
  package tables and purl-derived type/license/upstream links
- captain records verified against the DID's atcr_hold service before
  caching (processor + batch backfill), preventing forged holds
- fix empty-handle updates clobbering cached handles and colliding on
  the UNIQUE constraint
- move fillPrevCIDs into repo.go; DirectRepoOperator is now canonical,
  repomgr kept as a test oracle
- surface read-only crew status in hold selector
- reconcile docs
2026-06-13 12:49:03 -05:00

376 lines
22 KiB
HTML

{{ define "diff-content" }}
<!-- Layers + Vulnerabilities Diff -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Layer Diff (Left) -->
<div class="card bg-base-200 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
<h2 class="text-lg font-semibold">Layers</h2>
{{ if .LayerDiff }}
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Layer differences</caption>
<thead>
<tr class="text-xs">
<th scope="col" class="w-6"><span class="sr-only">Change</span></th>
<th scope="col" class="w-8">#</th>
<th scope="col">Command</th>
<th scope="col" class="text-right w-24">Size</th>
</tr>
</thead>
<tbody>
{{ range .LayerDiff }}
<tr class="{{ if eq .Status "added" }}bg-success/10{{ else if eq .Status "removed" }}bg-error/10{{ else if eq .Status "rebuilt" }}bg-warning/10{{ else }}opacity-60{{ end }}">
<td class="font-mono text-xs text-center font-bold {{ if eq .Status "added" }}text-success{{ else if eq .Status "removed" }}text-error{{ else if eq .Status "rebuilt" }}text-warning{{ end }}">{{/* Glyph + sr-only label: color is redundant information. */}}{{ if eq .Status "added" }}<span aria-hidden="true">+</span><span class="sr-only">Added</span>{{ else if eq .Status "removed" }}<span aria-hidden="true">-</span><span class="sr-only">Removed</span>{{ else if eq .Status "rebuilt" }}<span aria-hidden="true">~</span><span class="sr-only">Rebuilt</span>{{ else }}<span class="sr-only">Unchanged</span>{{ end }}</td>
<td class="font-mono text-xs">{{ .Layer.Index }}</td>
<td>
{{ if .Layer.Command }}
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Layer.Command }}">{{ .Layer.Command }}</code>
{{ end }}
</td>
<td class="text-right text-sm whitespace-nowrap">
{{ if not .Layer.EmptyLayer }}{{ humanizeBytes .Layer.Size }}{{ end }}
{{ if and (eq .Status "rebuilt") .PrevLayer }}
{{ if ne .Layer.Size .PrevLayer.Size }}
<span class="text-xs text-base-content/70">({{ humanizeByteDelta (sub64 .Layer.Size .PrevLayer.Size) }})</span>
{{ end }}
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ else }}
{{ template "state-empty" (dict
"Icon" "history"
"Title" "No layer history"
"Subtext" "Neither side of this diff reports layer history."
) }}
{{ end }}
</div>
<!-- Vulnerability + Package Diff (Right) -->
<div class="card bg-base-200 shadow-sm border border-base-300 p-6 min-w-0">
<div role="tablist" class="tabs tabs-bordered">
<input type="radio" id="diff-tab-vulns" name="diff-scan-tabs" role="tab" class="tab" aria-label="Vulnerabilities" aria-controls="diff-panel-vulns" checked="checked" />
<div id="diff-panel-vulns" role="tabpanel" aria-labelledby="diff-tab-vulns" class="tab-content pt-4 space-y-4">
{{ if not .HasVulnData }}
{{/* Branch on per-side scan status so users can tell "not scanned
yet" from "hold offline" from transient errors. */}}
{{ if or (eq .FromScanStatus "hold-unreachable") (eq .ToScanStatus "hold-unreachable") }}
<div class="alert alert-warning" role="status">
{{ icon "wifi-off" "size-4 shrink-0" }}
<span>We couldn't reach the hold to fetch scan data. Try again in a moment.</span>
</div>
{{ else if or (eq .FromScanStatus "no-data") (eq .ToScanStatus "no-data") }}
<p class="text-base-content/60">Neither manifest has been scanned yet. Vulnerability comparison will appear after both scans complete.</p>
{{ else }}
<p class="text-base-content/60">Vulnerability scan data isn't available for both manifests.</p>
{{ end }}
{{ else }}
<!-- Fixed Vulns -->
{{ if .FixedVulns }}
<div class="collapse collapse-arrow bg-success/5 border border-success/20 rounded-lg">
<input type="checkbox" checked aria-label="Toggle fixed vulnerabilities ({{ len .FixedVulns }})" />
<div class="collapse-title font-medium text-sm flex items-center gap-2">
{{ icon "shield-check" "size-4 text-success" }}
Fixed ({{ len .FixedVulns }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Vulnerabilities fixed in the newer manifest</caption>
<thead>
<tr class="text-xs">
<th scope="col">CVE</th>
<th scope="col">Severity</th>
<th scope="col">Package</th>
<th scope="col">Was</th>
</tr>
</thead>
<tbody>
{{ range .FixedVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a>
{{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span>
</td>
<td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
<!-- New Vulns -->
{{ if .NewVulns }}
<div class="collapse collapse-arrow bg-error/5 border border-error/20 rounded-lg">
<input type="checkbox" checked aria-label="Toggle new vulnerabilities ({{ len .NewVulns }})" />
<div class="collapse-title font-medium text-sm flex items-center gap-2">
{{ icon "alert-triangle" "size-4 text-error" }}
New ({{ len .NewVulns }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Vulnerabilities new to the newer manifest</caption>
<thead>
<tr class="text-xs">
<th scope="col">CVE</th>
<th scope="col">Severity</th>
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">Fix</th>
</tr>
</thead>
<tbody>
{{ range .NewVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a>
{{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span>
</td>
<td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td>
<td class="text-xs font-mono">{{ .FixedIn }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
<!-- Unchanged Vulns -->
{{ if .UnchangedVulns }}
<div class="collapse collapse-arrow bg-base-200/50 border border-base-300 rounded-lg">
<input type="checkbox" aria-label="Toggle unchanged vulnerabilities ({{ len .UnchangedVulns }})" />
<div class="collapse-title font-medium text-sm text-base-content/60">
Unchanged ({{ len .UnchangedVulns }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Vulnerabilities present in both manifests</caption>
<thead>
<tr class="text-xs">
<th scope="col">CVE</th>
<th scope="col">Severity</th>
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">Fix</th>
</tr>
</thead>
<tbody>
{{ range .UnchangedVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a>
{{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span>
</td>
<td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td>
<td class="text-xs font-mono">{{ .FixedIn }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
{{ if and (not .FixedVulns) (not .NewVulns) (not .UnchangedVulns) }}
<p class="text-base-content/60">No vulnerabilities found in either manifest</p>
{{ end }}
{{ end }}
</div>
<input type="radio" id="diff-tab-sbom" name="diff-scan-tabs" role="tab" class="tab" aria-label="Packages" aria-controls="diff-panel-sbom" />
<div id="diff-panel-sbom" role="tabpanel" aria-labelledby="diff-tab-sbom" class="tab-content pt-4 space-y-4">
{{ if not .HasSbomData }}
{{/* Mirror the vuln branching so users can tell "not scanned yet"
from "hold offline" from "this artifact type isn't scanned". */}}
{{ if or (eq .FromSbomStatus "hold-unreachable") (eq .ToSbomStatus "hold-unreachable") }}
<div class="alert alert-warning" role="status">
{{ icon "wifi-off" "size-4 shrink-0" }}
<span>We couldn't reach the hold to fetch SBOM data. Try again in a moment.</span>
</div>
{{ else if or (eq .FromSbomStatus "not-applicable") (eq .ToSbomStatus "not-applicable") }}
<p class="text-base-content/60">SBOMs aren't generated for this artifact type.</p>
{{ else if or (eq .FromSbomStatus "no-data") (eq .ToSbomStatus "no-data") }}
<p class="text-base-content/60">An SBOM isn't available for both manifests yet. Package comparison will appear after both scans complete.</p>
{{ else }}
<p class="text-base-content/60">SBOM data isn't available for both manifests.</p>
{{ end }}
{{ else }}
<!-- Changed Packages -->
{{ if .ChangedPackages }}
<div class="collapse collapse-arrow bg-warning/5 border border-warning/20 rounded-lg">
<input type="checkbox" checked aria-label="Toggle changed packages ({{ len .ChangedPackages }})" />
<div class="collapse-title font-medium text-sm flex items-center gap-2">
{{ icon "package" "size-4 text-warning" }}
Changed ({{ len .ChangedPackages }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Packages whose version changed between manifests</caption>
<thead>
<tr class="text-xs">
<th scope="col">Package</th>
<th scope="col">Type</th>
<th scope="col">Version</th>
</tr>
</thead>
<tbody>
{{ range .ChangedPackages }}
<tr>
<td class="text-xs truncate max-w-xs" title="{{ .Package.Name }}">{{ if .Package.URL }}<a href="{{ .Package.URL }}" target="_blank" rel="noopener noreferrer" class="link link-primary">{{ .Package.Name }}</a>{{ else }}{{ .Package.Name }}{{ end }}</td>
<td>{{ if .Package.Type }}<span class="badge badge-xs badge-ghost">{{ .Package.Type }}</span>{{ end }}</td>
<td class="text-xs font-mono truncate max-w-48" title="{{ .PrevVersion }} to {{ .Package.Version }}">{{ .PrevVersion }} <span aria-hidden="true"></span><span class="sr-only">to</span> {{ .Package.Version }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
<!-- Added Packages -->
{{ if .AddedPackages }}
<div class="collapse collapse-arrow bg-success/5 border border-success/20 rounded-lg">
<input type="checkbox" checked aria-label="Toggle added packages ({{ len .AddedPackages }})" />
<div class="collapse-title font-medium text-sm flex items-center gap-2">
{{ icon "package-plus" "size-4 text-success" }}
Added ({{ len .AddedPackages }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Packages only present in the newer manifest</caption>
<thead>
<tr class="text-xs">
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">Type</th>
<th scope="col">License</th>
</tr>
</thead>
<tbody>
{{ range .AddedPackages }}
<tr>
<td class="text-xs truncate max-w-xs" title="{{ .Package.Name }}">{{ if .Package.URL }}<a href="{{ .Package.URL }}" target="_blank" rel="noopener noreferrer" class="link link-primary">{{ .Package.Name }}</a>{{ else }}{{ .Package.Name }}{{ end }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Package.Version }}">{{ .Package.Version }}</td>
<td>{{ if .Package.Type }}<span class="badge badge-xs badge-ghost">{{ .Package.Type }}</span>{{ end }}</td>
<td class="text-xs truncate max-w-40" title="{{ .Package.License }}">{{ .Package.License }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
<!-- Removed Packages -->
{{ if .RemovedPackages }}
<div class="collapse collapse-arrow bg-error/5 border border-error/20 rounded-lg">
<input type="checkbox" checked aria-label="Toggle removed packages ({{ len .RemovedPackages }})" />
<div class="collapse-title font-medium text-sm flex items-center gap-2">
{{ icon "package-minus" "size-4 text-error" }}
Removed ({{ len .RemovedPackages }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<caption class="sr-only">Packages only present in the older manifest</caption>
<thead>
<tr class="text-xs">
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">Type</th>
<th scope="col">License</th>
</tr>
</thead>
<tbody>
{{ range .RemovedPackages }}
<tr>
<td class="text-xs truncate max-w-xs" title="{{ .Package.Name }}">{{ if .Package.URL }}<a href="{{ .Package.URL }}" target="_blank" rel="noopener noreferrer" class="link link-primary">{{ .Package.Name }}</a>{{ else }}{{ .Package.Name }}{{ end }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Package.Version }}">{{ .Package.Version }}</td>
<td>{{ if .Package.Type }}<span class="badge badge-xs badge-ghost">{{ .Package.Type }}</span>{{ end }}</td>
<td class="text-xs truncate max-w-40" title="{{ .Package.License }}">{{ .Package.License }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
<!-- Unchanged Packages -->
{{ if .UnchangedPackages }}
<div class="collapse collapse-arrow bg-base-200/50 border border-base-300 rounded-lg">
<input type="checkbox" aria-label="Toggle unchanged packages ({{ len .UnchangedPackages }})" />
<div class="collapse-title font-medium text-sm text-base-content/60">
Unchanged ({{ len .UnchangedPackages }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto overflow-y-auto max-h-128">
<table class="table table-xs w-full">
<caption class="sr-only">Packages identical in both manifests</caption>
<thead>
<tr class="text-xs">
<th scope="col">Package</th>
<th scope="col">Version</th>
<th scope="col">Type</th>
<th scope="col">License</th>
</tr>
</thead>
<tbody>
{{ range .UnchangedPackages }}
<tr>
<td class="text-xs truncate max-w-xs" title="{{ .Package.Name }}">{{ if .Package.URL }}<a href="{{ .Package.URL }}" target="_blank" rel="noopener noreferrer" class="link link-primary">{{ .Package.Name }}</a>{{ else }}{{ .Package.Name }}{{ end }}</td>
<td class="text-xs font-mono truncate max-w-40" title="{{ .Package.Version }}">{{ .Package.Version }}</td>
<td>{{ if .Package.Type }}<span class="badge badge-xs badge-ghost">{{ .Package.Type }}</span>{{ end }}</td>
<td class="text-xs truncate max-w-40" title="{{ .Package.License }}">{{ .Package.License }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
{{ if and (not .ChangedPackages) (not .AddedPackages) (not .RemovedPackages) (not .UnchangedPackages) }}
<p class="text-base-content/60">No packages found in either manifest</p>
{{ end }}
{{ end }}
</div>
</div>
</div>
</div>
{{ end }}