Documentation
¶
Overview ¶
Package report — attack-graph layout. Builds the 3-column SVG flow diagram (entries → capabilities → impacts) plus the parallel detail payload for the JS interactivity layer. All coordinates are precomputed here so the template emits raw SVG without any layout logic of its own.
Package report — short labels and severity classes for attack-graph nodes. Pure pure-data mapping helpers; no layout, no findings introspection beyond field reads.
Package report — Compliance Coverage tab data assembly. Takes the already-decorated findings (compliance.Apply ran during analysis) and re-projects them into a ComplianceSection that groups by framework → control. Hidden by the template when there are no findings with any compliance tag, so the tab disappears for snapshots whose rules happen to lack a mapping rather than rendering an empty shell.
Package report — combined educational block for the static Findings tab. renderFindingEducation pulls relevant Glossary entries (subject kind, resource kind) and the Techniques entry for the finding, then renders them as a "Background" block inside the "How an attacker abuses this" section. This brings the same explanatory copy that drives the interactive Attack Graph side-panel into every finding card, so a reader does not need to click into the graph to learn what a ServiceAccount is or what the impersonate verb does.
All HTML is constructed from in-process Glossary/Techniques values whose Long/Plain fields are already template.HTML. Subject/resource keys come from analyzer-supplied SubjectRef/ResourceRef but are routed through HTMLEscapeString before composing.
Package report — evidence glossary. Static lookup tables that turn opaque cluster observations (verb names, host paths, CIDRs, secret types, …) into one-sentence "what does this mean and why does it matter" hints rendered next to the raw value in the Findings tab. Keep entries terse: the surrounding finding card already carries Description / Impact / AttackScenario prose, so this is annotation, not exposition.
Package report — humanized rendering of Finding.Evidence and Finding.EscalationPath for the static Findings tab. The analyzer-emitted Evidence payload is a small `map[string]any` per rule; rather than show it as raw JSON (opaque to a Kubernetes newcomer), we walk the known keys and render them as labeled rows with chips and glossary hints. Unknown keys fall back to inline JSON, so future analyzers degrade gracefully without code changes here.
All HTML is built via html/template's HTMLEscapeString — no template.HTML is ever constructed from analyzer-supplied content. This matches the safety pattern in renderInlineCode / renderParagraphs.
Package report — glossary and explainer copy used by the interactive attack-graph in the HTML report. This is presentation-layer content: it deliberately does not live on models.Finding so it stays out of JSON/CSV/SARIF outputs, and lets us iterate on copy without re-running scans.
All HTML strings are rendered into a self-contained report — no remote assets, no <script> content, only inline markup for typography (<code>, <strong>, <em>, <p>).
Package report - Least Privilege tab section builder. Filters findings down to the LP rule-ID set, groups them by subject, and packages window summary + counts for the template. Pure data layer; the HTML markup lives in report.html.tmpl.
Package report — markdown rendering for finding titles, descriptions, impacts, scenarios, and remediations. Supports a tiny subset (paragraphs, line breaks, inline `code`, **bold**) so finding text can be authored once and rendered safely in HTML.
Package report — auto-generated attack-chain narratives. Detects well-known multi-rule chains in the finding set and emits NarrativeCards (plain-English walkthroughs) for the HTML report. Also provides the headline copy that summarizes the overall posture.
Package report - render-time options threaded from the CLI. Kept in a small file so the surface is easy to scan: DefaultTab flips which HTML tab is active on load, UsageInfo carries the audit-log window summary that the Least Privilege tab renders in its header.
Package report — top-of-report reconnaissance panel data builder. The panel surfaces pentester-relevant snapshot facts at the top of the HTML report so a reader can grasp blast radius, who already owns the cluster, what is exposed, and what guardrails are missing before scrolling to the Findings tab.
Everything here is derived from a Snapshot at render time — adding or removing rows is a report-layer concern only, no collector or model change required.
Package report renders scanner output in the formats users consume: a human-friendly HTML dashboard, machine-readable findings JSON, a triage CSV, and SARIF for IDE/CI tooling. It is a pure render step and does not mutate findings.
Package report — risk-index gauge math used by the HTML dashboard's headline score and arc rendering.
Package report — summary, grouping, and hotspot helpers used to assemble the HTML report's high-level views (severity tallies, module/category sections, top namespaces, and the resource × category heatmap).
Package report - "Top 5 fixes" panel builder. Groups findings by the principal or resource that owns them, sums per-group scores, and ranks the resulting buckets so the operator sees the single highest-leverage cleanup at the top of the report. One ServiceAccount with overbroad RBAC plus a privesc path will typically dominate the cluster's risk index; collapsing those rows into a single "delete this binding" recommendation turns the report from a 200-row triage list into a five-step plan.
Package report — types shared across the report package: data shapes consumed by the HTML template, the format writers, and the JSON/CSV/SARIF marshallers.
Package report — format writers and graph payload marshaling. writeJSON / writeCSV / writeHTML are the per-format outputs dispatched from Write; SARIF lives in sarif.go.
Index ¶
- Variables
- func BuildHTMLData(snapshot models.Snapshot, findings []models.Finding) htmlReportData
- func GlossaryKeyForResource(ref *models.ResourceRef) string
- func GlossaryKeyForSubject(ref *models.SubjectRef) string
- func GuessAdmissionSummaryPath(findingsPath string) string
- func GuessMetadataPath(findingsPath string) string
- func GuessTruncationInfoPath(findingsPath string) string
- func ReadAdmissionSummary(path string) (models.AdmissionSummary, error)
- func ReadFindings(path string) ([]models.Finding, error)
- func ReadMetadata(path string) (models.SnapshotMetadata, error)
- func ReadTruncationInfo(path string) (models.TruncationInfo, error)
- func TechniqueKeyForFinding(f models.Finding) string
- func Truncate(findings []models.Finding, limit int, allFindings bool) ([]models.Finding, models.TruncationInfo)
- func Write(outputDir string, formats []string, snapshot models.Snapshot, ...) ([]string, error)
- func WriteAdmissionSummary(outputDir string, summary models.AdmissionSummary) (string, error)
- func WriteMetadata(outputDir string, metadata models.SnapshotMetadata) (string, error)
- func WriteTruncationInfo(outputDir string, info models.TruncationInfo) (string, error)
- func WriteWithAdmission(outputDir string, formats []string, snapshot models.Snapshot, ...) ([]string, error)
- func WriteWithOptions(outputDir string, formats []string, snapshot models.Snapshot, ...) ([]string, error)
- type AttackGraph
- type AttackerStep
- type CategoryExplainer
- type CategorySection
- type ComplianceControlRow
- type ComplianceFrameworkView
- type ComplianceSection
- type GlossaryEntry
- type GraphEdge
- type GraphEdgeDetail
- type GraphLane
- type GraphNode
- type GraphNodeDetail
- type GraphPayload
- type GraphTextLine
- type HeatmapCategory
- type HeatmapCell
- type HeatmapRow
- type HeroChainCard
- type HopView
- type Hotspot
- type LPClusterAdminRow
- type LPRoleVerbRow
- type LPUnusedResourceRow
- type LeastPrivilegeFindingCard
- type LeastPrivilegeGroup
- type LeastPrivilegeSection
- type ModuleSection
- type NarrativeCard
- type NarrativeStep
- type Options
- type Recon
- type ReconChip
- type ReconGuardrails
- type ReconOwnership
- type ReconProvenance
- type ReconResourceList
- type ReconShape
- type ReconSubjectList
- type ReconSurface
- type RuleGroup
- type ScoringBreakdown
- type SubjectCapabilityCard
- type Summary
- type TOCEntry
- type TechniqueExplainer
- type TopFix
- type UsageInfo
Constants ¶
This section is empty.
Variables ¶
var Categories = map[string]CategoryExplainer{ string(models.CategoryPrivilegeEscalation): { Title: "Privilege Escalation", Plain: template.HTML(`<p>An identity that started with limited permissions ends up acting as cluster-admin (or <code>system:masters</code>, or the kubelet on a node). Privilege escalation is the gateway impact: every other category becomes possible once an attacker has it.</p>`), Examples: []string{ "A compromised application pod's ServiceAccount mints a cluster-admin token via TokenRequest.", "A namespace-admin abuses bind/escalate to grant themselves cluster-admin.", "A privileged DaemonSet's pod is exec'd into and the attacker pivots to the node, then to the kubelet's credentials.", }, }, string(models.CategoryLateralMovement): { Title: "Lateral Reach", Plain: template.HTML(`<p>The attacker spreads sideways: across namespaces, across nodes, or out of the pod onto cluster-internal services. NetworkPolicy gaps, default-allow service meshes, and over-broad ServiceAccounts in shared namespaces all enable this.</p>`), Examples: []string{ "From a compromised pod, reach the API server, etcd metrics endpoints, or internal databases that have no NetworkPolicy.", "hostNetwork pods can sniff or spoof traffic for every other pod on the same node.", }, }, string(models.CategoryDataExfiltration): { Title: "Data Exfiltration", Plain: template.HTML(`<p>Reading data the attacker should not see: Secrets, ConfigMap-stored credentials, application data in PersistentVolumes, or audit logs that reveal internal structure.</p>`), Examples: []string{ "Reading every Secret in kube-system to harvest controller credentials.", "Mounting a PersistentVolume from a database StatefulSet.", "Pulling registry pull-secrets and using them to clone private images.", }, }, string(models.CategoryInfrastructureModification): { Title: "Control Bypass", Plain: template.HTML(`<p>The attacker turns off, weakens, or works around the cluster's policy enforcement: admission webhooks, Pod Security admission, OPA/Gatekeeper, or audit configuration. After this, follow-on actions become invisible to defenders.</p>`), Examples: []string{ "Patching a `ValidatingWebhookConfiguration` to `failurePolicy: Ignore`, then deleting the backing service.", "Removing a Pod Security label from a namespace to allow privileged pods.", }, }, string(models.CategoryDefenseEvasion): { Title: "Detection Evasion", Plain: template.HTML(`<p>The attacker hides their tracks: disabling audit logging, deleting events, rolling back resource versions, or abusing legitimate-looking patterns (impersonation, service accounts) so the activity blends in.</p>`), Examples: []string{ "Using `--as=system:serviceaccount:kube-system:replicaset-controller` to impersonate a high-volume controller and disappear in audit log noise.", "Deleting Events that record the privileged pod's creation.", }, }, }
Categories maps a RiskCategory to plain-language explainer copy used on the impact-lane nodes.
var Glossary = map[string]GlossaryEntry{ "ServiceAccount": { Title: "ServiceAccount", Short: "An identity used by pods (not humans) to call the Kubernetes API.", Long: template.HTML(`<p>A <strong>ServiceAccount</strong> is an in-cluster identity assigned to pods. Every pod gets a token mounted at <code>/var/run/secrets/kubernetes.io/serviceaccount/token</code>, and that token <em>is</em> the credential. If an attacker reads that file from inside a compromised container (or creates a pod that mounts the token), they can call the API <em>as</em> the ServiceAccount, with whatever permissions the SA has been granted.</p><p>This is the most common pivot in real-world Kubernetes attacks: compromise one pod, steal its token, ride the token to wherever its RBAC allows.</p>`), DocURL: "https://kubernetes.io/docs/concepts/security/service-accounts/", }, "Group": { Title: "Group", Short: "A label attached to authenticated identities (for example, system:masters acts as cluster-admin).", Long: template.HTML(`<p>A <strong>Group</strong> is a string label associated with users or ServiceAccounts at authentication time. RoleBindings can target groups, so <em>everyone</em> in the group inherits the bound permissions. Two groups deserve special care:</p><ul><li><code>system:masters</code>: hardcoded as cluster-admin. Membership is permanent (you cannot un-grant it via RBAC).</li><li><code>system:authenticated</code>: every authenticated identity. Bind anything sensitive to this and you grant it to the world.</li></ul><p>Groups are assigned by the authenticator (OIDC claims, certificate organization fields, etc.), not stored in the API server, which means they don't appear in <code>kubectl get</code>.</p>`), DocURL: "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#default-roles-and-role-bindings", }, "User": { Title: "User", Short: "A human (or external automation) authenticated by certs, OIDC, or static tokens.", Long: template.HTML(`<p>A <strong>User</strong> in Kubernetes is whoever the authenticator says they are. There is no User object in the API. Identity comes from a client certificate's CN, an OIDC <code>sub</code> claim, a webhook, or a static token. RBAC then targets that identity by name. If you see "User foo" in a finding, foo is whatever string the authentication layer produced.</p>`), DocURL: "https://kubernetes.io/docs/reference/access-authn-authz/authentication/", }, "Pod": { Title: "Pod", Short: "The smallest schedulable unit: one or more containers sharing a network and storage namespace.", Long: template.HTML(`<p>A <strong>Pod</strong> wraps one or more containers that share an IP, hostname, and volumes. From an attacker's perspective, a pod is a foothold: the API token mounted into it grants the pod's ServiceAccount permissions; if the pod is privileged or mounts the host filesystem, it's also a path to escape onto the node.</p>`), DocURL: "https://kubernetes.io/docs/concepts/workloads/pods/", }, "Deployment": { Title: "Deployment", Short: "A controller that keeps N copies of a pod running, with rolling updates.", Long: template.HTML(`<p>A <strong>Deployment</strong> manages a ReplicaSet which manages Pods. The dangerous attribute lives on the pod template: every replica inherits the same ServiceAccount, the same securityContext, and the same volume mounts. A risky pod template multiplies into N risky pods.</p>`), DocURL: "https://kubernetes.io/docs/concepts/workloads/controllers/deployment/", }, "DaemonSet": { Title: "DaemonSet", Short: "Runs a copy of a pod on every node (often privileged: log collectors, CNI agents).", Long: template.HTML(`<p>A <strong>DaemonSet</strong> schedules one pod per node, typically for cluster infrastructure (CNI, log shipping, node monitoring). DaemonSets are frequent targets because they often need <code>hostNetwork</code>, <code>hostPath</code>, or <code>privileged</code> to do their job, which makes them ideal for attackers if compromised.</p>`), }, "StatefulSet": { Title: "StatefulSet", Short: "Pods with stable identities and persistent storage: databases, queues.", Long: template.HTML(`<p>A <strong>StatefulSet</strong> gives each pod a stable DNS name and dedicated PersistentVolume. Compromise here often means access to durable application data: databases, message queues, caches.</p>`), }, "ReplicaSet": { Title: "ReplicaSet", Short: "Maintains a stable set of pod replicas. Usually managed by a Deployment.", Long: template.HTML(`<p>A <strong>ReplicaSet</strong> keeps a target number of identical pods running. You normally don't manage these directly; a Deployment owns them.</p>`), }, "Job": { Title: "Job", Short: "Runs a pod (or pods) to completion: batch tasks, migrations.", Long: template.HTML(`<p>A <strong>Job</strong> executes one or more pods that must complete successfully. Jobs are a common attacker mechanism for one-shot privilege use ("create a Job that mounts the host filesystem, do the thing, exit").</p>`), }, "Secret": { Title: "Secret", Short: "Stores credentials: TLS keys, registry creds, API tokens, ServiceAccount tokens.", Long: template.HTML(`<p>A <strong>Secret</strong> holds sensitive data: registry pull credentials, TLS private keys, ServiceAccount tokens. Secrets are base64-encoded, <em>not</em> encrypted by default. Anyone with <code>get</code> on the Secret resource can read the contents in cleartext. <code>get</code>/<code>list</code>/<code>watch</code> on Secrets in <code>kube-system</code> is effectively cluster-admin: that namespace holds the controller-manager and kube-scheduler tokens.</p>`), DocURL: "https://kubernetes.io/docs/concepts/configuration/secret/", }, "ConfigMap": { Title: "ConfigMap", Short: "Non-sensitive key/value config data injected into pods. Often misused for credentials.", Long: template.HTML(`<p>A <strong>ConfigMap</strong> stores plain-text configuration that pods read at startup. They are <em>not</em> meant to hold secrets, but in practice teams put database URLs (with passwords), API keys, and tokens in ConfigMaps. Kubesplaining flags credential-shaped keys for that reason.</p>`), }, "ClusterRole": { Title: "ClusterRole", Short: "A cluster-wide bag of (verbs × resources) permissions, granted via a binding.", Long: template.HTML(`<p>A <strong>ClusterRole</strong> is a named set of permissions ("can <code>get</code>/<code>list</code> on <code>pods</code> across the cluster"). It does nothing on its own; it must be granted to a subject through a ClusterRoleBinding (cluster-wide) or RoleBinding (one namespace).</p><p>The infamous <code>cluster-admin</code> ClusterRole grants <code>verbs: ["*"]</code> on <code>resources: ["*"]</code> in <code>apiGroups: ["*"]</code>, which is total control.</p>`), DocURL: "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", }, "ClusterRoleBinding": { Title: "ClusterRoleBinding", Short: "Grants a ClusterRole's permissions to a subject across the entire cluster.", Long: template.HTML(`<p>A <strong>ClusterRoleBinding</strong> assigns a ClusterRole to subjects (Users, Groups, ServiceAccounts) at cluster scope, not just one namespace. A binding to <code>cluster-admin</code> here means the subject can do anything anywhere. Always look at <em>both</em> what role is bound <em>and</em> who it is bound to.</p>`), DocURL: "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", }, "Role": { Title: "Role", Short: "Like a ClusterRole, but scoped to a single namespace.", Long: template.HTML(`<p>A <strong>Role</strong> is a permission set that only applies inside one namespace. Roles cannot reference cluster-scoped resources (like Nodes or PersistentVolumes).</p>`), }, "RoleBinding": { Title: "RoleBinding", Short: "Grants a Role (or ClusterRole) to a subject, scoped to one namespace.", Long: template.HTML(`<p>A <strong>RoleBinding</strong> assigns permissions inside a single namespace. It can reference a Role from the same namespace or a ClusterRole. When it references a ClusterRole, the permissions still only apply inside the binding's namespace.</p>`), }, "Namespace": { Title: "Namespace", Short: "A logical partition for resources: most policies, quotas, and RBAC scope to one.", Long: template.HTML(`<p>A <strong>Namespace</strong> divides cluster resources by team, environment, or application. RoleBindings, NetworkPolicies, ResourceQuotas, and most admission rules apply at namespace scope. Compromising one workload in a namespace often gives lateral access to the rest of that namespace's resources.</p>`), }, "hostPath": { Title: "hostPath volume", Short: "Mounts a directory from the underlying node into the pod (a classic container-escape vector).", Long: template.HTML(`<p>A <strong>hostPath</strong> volume bind-mounts a path from the host node into the container. Sensitive paths like <code>/</code>, <code>/etc</code>, <code>/var/run/docker.sock</code>, or <code>/var/lib/kubelet</code> turn the pod into a node-takeover primitive: an attacker inside the pod can write systemd units, read the kubelet's credentials, or directly invoke the container runtime to start privileged containers on the host.</p>`), DocURL: "https://kubernetes.io/docs/concepts/storage/volumes/#hostpath", }, "PersistentVolume": { Title: "PersistentVolume", Short: "A cluster-scoped storage handle. PVs that wrap hostPath bypass Pod Security Admission.", Long: template.HTML(`<p>A <strong>PersistentVolume</strong> is a cluster-scoped storage object that a PersistentVolumeClaim binds to. PVs come from many sources: CSI drivers, NFS, iSCSI, and (dangerously) <code>hostPath</code>. The Pod Security Admission controller inspects the PodSpec only and never follows the PVC -> PV indirection, so a PV that wraps a sensitive hostPath (<code>/</code>, <code>/etc</code>, <code>/var/lib/kubelet</code>, the container runtime sockets) becomes an unobservable node-escape primitive: a Pod in a Baseline- or Restricted-enforced namespace can mount the equivalent of a sensitive hostPath simply by claiming the PV.</p><p>PVs are non-namespaced. Whoever can create or modify PVs can therefore expose sensitive node directories to any tenant in the cluster, regardless of namespace boundaries.</p>`), DocURL: "https://kubernetes.io/docs/concepts/storage/persistent-volumes/", }, "PrivilegedContainer": { Title: "Privileged container", Short: "Runs with kernel-level access to the host (equivalent to root on the node).", Long: template.HTML(`<p>Setting <code>securityContext.privileged: true</code> disables most container isolation: the container gets every Linux capability, can access all host devices, and can mount host filesystems. From a privileged container an attacker can escape to the node trivially (<code>nsenter</code>, mount the host's <code>/</code>, write a SUID binary, etc.).</p>`), DocURL: "https://kubernetes.io/docs/concepts/security/pod-security-standards/", }, "hostNetwork": { Title: "hostNetwork", Short: "Pod shares the node's network namespace, so it sees and binds host ports.", Long: template.HTML(`<p>With <code>hostNetwork: true</code>, the pod sees the host's network interfaces directly. It can reach the kubelet on <code>localhost:10250</code>, sniff or spoof traffic between other workloads on that node, and bind privileged ports without going through the CNI.</p>`), }, "hostPID": { Title: "hostPID", Short: "Pod sees and can signal every process on the node, including the kubelet.", Long: template.HTML(`<p>With <code>hostPID: true</code>, the pod's PID namespace is the host's. It can <code>ps</code> all processes (including the kubelet), read <code>/proc/<pid>/environ</code> for credentials, and join other processes' namespaces with <code>nsenter</code>.</p>`), }, "RunAsRoot": { Title: "Container runs as root (UID 0)", Short: "No runAsNonRoot constraint, so kernel exploits and breakout primitives apply.", Long: template.HTML(`<p>Containers that run as UID 0 are not automatically dangerous, but they remove a layer of defence. Combined with capabilities or hostPath, root-in-container becomes root-on-node much more easily.</p>`), }, "Capabilities": { Title: "Linux capabilities", Short: "Fine-grained kernel privileges: SYS_ADMIN, NET_ADMIN, and similar are container-escape primitives.", Long: template.HTML(`<p>Linux <strong>capabilities</strong> split root's powers into ~40 buckets. <code>SYS_ADMIN</code> alone is enough to mount filesystems and break out of most container runtimes. <code>NET_ADMIN</code> allows traffic redirection. <code>SYS_PTRACE</code> lets a container read other processes' memory. The principle is: drop everything, add only what is needed.</p>`), }, "NetworkPolicy": { Title: "NetworkPolicy", Short: "Cluster-internal firewall. Without one, every pod can talk to every other pod.", Long: template.HTML(`<p>A <strong>NetworkPolicy</strong> restricts which pods can talk to which. The default in Kubernetes is <em>allow-all</em>: a pod with no NetworkPolicies covering it can reach every pod in the cluster, including the API server, etcd via metrics endpoints, and internal services. Lateral movement after pod compromise depends on whether NetworkPolicies are enforced.</p>`), }, "AdmissionWebhook": { Title: "Admission webhook", Short: "A pluggable validator/mutator for API requests; failurePolicy: Ignore is a security gap.", Long: template.HTML(`<p>An <strong>admission webhook</strong> intercepts API requests before they are persisted, allowing custom policy. If a security-critical webhook has <code>failurePolicy: Ignore</code>, an outage of the webhook backend silently disables enforcement, and attackers can race a webhook restart and slip through.</p>`), }, "kube-system": { Title: "kube-system namespace", Short: "Holds the control plane's ServiceAccounts and tokens. Read-access here is cluster-admin.", Long: template.HTML(`<p>The <strong>kube-system</strong> namespace contains tokens for the controller-manager, kube-scheduler, and other privileged controllers. Anyone who can <code>get</code>/<code>list</code>/<code>watch</code> Secrets in kube-system can read those tokens and act as those controllers, which is effectively cluster-admin.</p>`), }, "system:masters": { Title: "system:masters group", Short: "Hardcoded as cluster-admin. Membership cannot be revoked through RBAC.", Long: template.HTML(`<p>The <strong>system:masters</strong> group is special-cased in the API server: members bypass RBAC and act as cluster-admin. The membership comes from the authenticator (typically certificate <code>O=system:masters</code>) and <em>cannot</em> be removed by deleting bindings; it is wired in below the RBAC layer.</p>`), }, "ResourceQuota": { Title: "ResourceQuota", Short: "Namespace-scoped cap on CPU, memory, and object counts (prevents noisy-neighbor and DoS).", Long: template.HTML(`<p>A <strong>ResourceQuota</strong> caps total compute and object counts inside a namespace: aggregate CPU / memory requests and limits across all pods, plus per-kind object counts (Pods, Services, Secrets, PVCs). Once a quota exists, the kube-apiserver rejects any pod whose containers do not declare matching <code>resources.requests</code> and <code>resources.limits</code> for the quota's tracked resources. ResourceQuota is the namespace-level multi-tenancy backstop: without it, a single workload can starve every co-tenant on the same node, or an attacker who lands code execution can spawn unlimited replicas / Secrets / Pods until something breaks.</p>`), }, "LivenessProbe": { Title: "Liveness probe", Short: "Periodic kubelet check that restarts a container when it stops responding.", Long: template.HTML(`<p>A <strong>livenessProbe</strong> is a periodic HTTP / TCP / exec / gRPC check the kubelet runs against the container. When it fails for <code>failureThreshold</code> consecutive intervals, the kubelet kills and restarts the container. Liveness solves the "PID 1 is alive but wedged" case: a deadlocked thread, an infinite GC loop, or a stuck-on-startup dependency. It is intentionally <em>different</em> from the <code>readinessProbe</code> (which gates Service endpoint membership, not restart). The correct shape is a tiny <code>/livez</code> handler with no downstream dependencies; a liveness probe that touches the database will restart the pod every time the database hiccups, amplifying outages.</p>`), }, "LimitRange": { Title: "LimitRange", Short: "Namespace-scoped default + min/max for pod / container resource requests and limits.", Long: template.HTML(`<p>A <strong>LimitRange</strong> declares per-namespace default <code>requests</code> / <code>limits</code> for pod and container resources, plus optional <code>min</code> / <code>max</code> bounds the kube-apiserver enforces at admission. When a pod is created without explicit resources, the LimitRange default is injected, so authors who forget the limits no longer ship <code>BestEffort</code> workloads by accident. Combined with a namespace <code>ResourceQuota</code> the pair gives operators a default-on baseline (LimitRange) plus a hard cap (ResourceQuota), which together prevent both noisy-neighbor failures and unbounded resource consumption from a single misconfiguration.</p>`), }, "CertificateSigningRequest": { Title: "CertificateSigningRequest", Short: "API resource for requesting a signed client / serving certificate from the cluster CA.", Long: template.HTML(`<p>A <strong>CertificateSigningRequest</strong> (CSR) carries an x509 signing request that asks the cluster's CA to issue a certificate. The signer is named via <code>spec.signerName</code> (e.g. <code>kubernetes.io/kube-apiserver-client</code> for API authentication, <code>kubernetes.io/kubelet-serving</code> for node serving certs). The CSR's Subject DN is attacker-controlled: the <code>CN</code> becomes the authenticated User and each <code>O</code> (Organization) becomes a Group.</p><p>Two RBAC verbs control the lifecycle: <code>create</code> on <code>certificatesigningrequests</code> lets you submit a CSR, and <code>update</code>/<code>patch</code> on the <code>certificatesigningrequests/approval</code> subresource lets you mark it Approved. The kube-controller-manager then signs whatever was approved. Holding both verbs is equivalent to cluster-admin: submit a CSR with <code>O=system:masters</code>, self-approve, retrieve the signed cert, and authenticate as <code>system:masters</code> (which the apiserver hard-codes as cluster-admin). Cert lifetime is whatever the signer applies (often a year), and revoking the original RBAC grant does not invalidate already-issued certs.</p>`), DocURL: "https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/", }, }
Glossary maps a stable key (subject Kind, resource Kind, or k8s concept name) to its teaching entry. Keys must match the GlossaryKey set on GraphNodeDetail at build time.
var Techniques = map[string]TechniqueExplainer{ "impersonate_system_masters": { Title: "Impersonation of system:masters", Plain: template.HTML(`<p>The <code>impersonate</code> verb on <code>groups: ["*"]</code> (or explicitly on <code>system:masters</code>) lets the holder send requests as the hard-coded <code>system:masters</code> group. The kube-apiserver short-circuits authorization for that group, so every API call succeeds regardless of RBAC.</p><p>This is the worst-case impersonation grant: it bypasses the cluster's entire RBAC layer rather than borrowing another principal's permissions.</p>`), Mitre: "T1078.004 — Cloud Accounts", AttackerSteps: []AttackerStep{ {Note: "Confirm the bypass works by querying as system:masters", Cmd: "kubectl auth can-i --list --as=system:masters --as-group=system:masters"}, {Note: "Read every Secret cluster-wide", Cmd: "kubectl --as=system:masters --as-group=system:masters get secrets -A"}, }, }, "mint_arbitrary_token": { Title: "Mint a token for any ServiceAccount", Plain: template.HTML(`<p>The <code>create</code> verb on <code>serviceaccounts/token</code> at cluster scope (without <code>resourceNames</code>) lets the holder mint a fresh, valid token for <em>any</em> ServiceAccount in any namespace. No pod creation or exec needed, and it leaves a thinner audit trail than the pod-mount route.</p>`), Mitre: "T1528 — Steal Application Access Token", AttackerSteps: []AttackerStep{ {Note: "Mint a 24h token for a privileged ServiceAccount", Cmd: "kubectl create token <sa> -n <ns> --duration=24h"}, {Note: "Call the API as the ServiceAccount using the minted token", Cmd: "curl --header 'Authorization: Bearer <token>' https://kubernetes.default.svc/api/..."}, }, }, "impersonate": { Title: "RBAC impersonation", Plain: template.HTML(`<p>Kubernetes has a built-in "act as another user" feature: the <code>impersonate</code> verb on <code>users</code>, <code>groups</code>, or <code>serviceaccounts</code>. Anyone with that verb can submit requests as <em>any</em> identity, bypassing whatever permissions they don't have themselves.</p><p>Granting <code>impersonate</code> on <code>groups</code> = <code>["*"]</code> is equivalent to cluster-admin: the holder can impersonate <code>system:masters</code>.</p>`), Mitre: "T1078.004 — Cloud Accounts", AttackerSteps: []AttackerStep{ {Note: "Confirm impersonation works", Cmd: "kubectl auth can-i --list --as=system:masters"}, {Note: "Exfiltrate every secret", Cmd: "kubectl --as=system:masters get secrets -A"}, {Note: "Pin permanent cluster-admin for an attacker-controlled user", Cmd: "kubectl --as=system:masters create clusterrolebinding pwn --clusterrole=cluster-admin --user=attacker"}, }, }, "impersonate_serviceaccount": { Title: "Namespace-scoped ServiceAccount impersonation", Plain: template.HTML(`<p>The <code>impersonate</code> verb on <code>serviceaccounts</code>, granted by a namespace-scoped <strong>RoleBinding</strong>, lets the holder act as any ServiceAccount that lives <em>in the binding's namespace</em>. The reach is bounded — they can't impersonate SAs in other namespaces, and they can't impersonate users or groups — but it's still a token-free credential-borrow that inherits whatever cluster-wide permissions the impersonated SA happens to have.</p><p>Real exposure depends on what the SAs in the namespace can do: an in-namespace controller SA bound to a powerful ClusterRole becomes a stepping stone out of the namespace.</p>`), Mitre: "T1078.004 — Cloud Accounts", AttackerSteps: []AttackerStep{ {Note: "List the SAs you can impersonate (every SA in the binding's namespace)", Cmd: "kubectl get sa -n <ns>"}, {Note: "Borrow a target SA's identity and probe its reach", Cmd: "kubectl auth can-i --list --as=system:serviceaccount:<ns>:<target-sa>"}, {Note: "Read whatever the impersonated SA can read (Secrets, ConfigMaps, etc.)", Cmd: "kubectl --as=system:serviceaccount:<ns>:<target-sa> get secrets -A"}, }, }, "bind_or_escalate": { Title: "RBAC bind/escalate bypass", Plain: template.HTML(`<p>RBAC has a guardrail: you can only grant permissions you yourself hold. Two verbs override that guardrail: <code>bind</code> (on a Role/ClusterRole) and <code>escalate</code> (also on Roles). Holding either lets the attacker create a binding to a Role they don't have themselves, including <code>cluster-admin</code>.</p><p>Scope matters. Granted by a ClusterRoleBinding the reach is cluster-wide; granted by a RoleBinding it bounds the bypass to the binding's namespace — namespace-admin instead of cluster-admin, but still a complete takeover of every workload, Secret, and ConfigMap in that namespace.</p>`), Mitre: "T1098.003 — Account Manipulation: Additional Cloud Roles", AttackerSteps: []AttackerStep{ {Note: "Bind a chosen ServiceAccount to cluster-admin", Cmd: "kubectl create clusterrolebinding pwn --clusterrole=cluster-admin --serviceaccount=ns:me"}, {Note: "Verify cluster-admin reach", Cmd: "kubectl get secrets -A"}, }, }, "pod_create_token_theft": { Title: "Pod creation → ServiceAccount token theft", Plain: template.HTML(`<p>Anyone who can create pods in a namespace can mount any ServiceAccount in that namespace into the pod. Cluster-scoped pod-create lets you mount any ServiceAccount in <em>any</em> namespace. Once the pod is running, the attacker reads <code>/var/run/secrets/kubernetes.io/serviceaccount/token</code> from inside it, and now holds a token for that SA.</p><p>This is the single most common privilege-escalation pattern in production Kubernetes.</p>`), Mitre: "T1528 — Steal Application Access Token", AttackerSteps: []AttackerStep{ {Note: "Spin up a pod that mounts the privileged ServiceAccount's token", Cmd: "kubectl run thief --image=alpine --serviceaccount=privileged-sa --command -- sleep infinity"}, {Note: "Read the mounted token from inside the pod", Cmd: "kubectl exec thief -- cat /var/run/secrets/kubernetes.io/serviceaccount/token"}, {Note: "Call the API as the stolen ServiceAccount", Cmd: "curl --header 'Authorization: Bearer <token>' https://kubernetes.default.svc/api/..."}, }, }, "pod_exec": { Title: "Pod exec → container takeover", Plain: template.HTML(`<p>The <code>pods/exec</code> subresource opens a shell inside a running container. If the container's pod uses a privileged ServiceAccount, the attacker inherits that SA's reach. If the container is itself privileged or mounts the host, this is also a node-escape primitive.</p>`), Mitre: "T1611 — Escape to Host", AttackerSteps: []AttackerStep{ {Note: "Open a shell inside a pod whose ServiceAccount is privileged", Cmd: "kubectl exec -it <pod-with-privileged-sa> -- /bin/sh"}, {Note: "Read the mounted ServiceAccount token", Cmd: "cat /var/run/secrets/kubernetes.io/serviceaccount/token"}, }, }, "token_request": { Title: "TokenRequest minting", Plain: template.HTML(`<p>The <code>create</code> verb on <code>serviceaccounts/token</code> mints a fresh, valid token for any ServiceAccount in scope, with no pod required. Cleaner than the pod-creation route and harder to spot in audit logs.</p>`), Mitre: "T1528 — Steal Application Access Token", AttackerSteps: []AttackerStep{ {Note: "Mint a fresh 24h token for any ServiceAccount in scope", Cmd: "kubectl create token <sa> --duration=24h --bound-object-kind=Pod --bound-object-name=irrelevant"}, {Note: "Call the API as the ServiceAccount using the minted token", Cmd: "curl --header 'Authorization: Bearer <token>' https://kubernetes.default.svc/api/..."}, }, }, "bound_to_cluster_admin": { Title: "Direct cluster-admin binding", Plain: template.HTML(`<p>The subject is bound directly to the <code>cluster-admin</code> ClusterRole through a ClusterRoleBinding. No chain is needed; they are already cluster-admin. The only question is whether the subject itself can be compromised.</p>`), Mitre: "T1078 — Valid Accounts", AttackerSteps: []AttackerStep{ {Note: "Enumerate every subject already bound to cluster-admin", Cmd: "kubectl get clusterrolebinding -o json | jq '.items[] | select(.roleRef.name==\"cluster-admin\") | .subjects'"}, {Note: "Compromise any subject in that list. Done."}, }, }, "wildcard_permission": { Title: "Wildcard verbs × wildcard resources", Plain: template.HTML(`<p>An RBAC rule with <code>verbs: ["*"]</code>, <code>resources: ["*"]</code>, and <code>apiGroups: ["*"]</code> is functionally identical to cluster-admin, even if it isn't called that. Often introduced by careless Helm charts or "give it permission to everything until it works" debugging.</p>`), Mitre: "T1078 — Valid Accounts", }, "modify_role_binding": { Title: "RoleBinding write access", Plain: template.HTML(`<p><code>create</code>/<code>update</code>/<code>patch</code> on <code>rolebindings</code> or <code>clusterrolebindings</code> lets the attacker bind themselves to any role, typically cluster-admin. They don't need the role's permissions today, only the ability to change bindings.</p><p>Scope matters. Granted at cluster scope (via a ClusterRoleBinding, or with cluster-wide reach on <code>rolebindings</code>) the reach is cluster-admin equivalent. Granted by a RoleBinding the reach is bounded to that one namespace — full namespace-admin, but the bound ClusterRole's verbs apply only inside the binding's namespace.</p>`), Mitre: "T1098 — Account Manipulation", AttackerSteps: []AttackerStep{ {Note: "Append yourself as a subject on an existing high-privilege binding", Cmd: "kubectl patch clusterrolebinding existing-binding --type=json -p='[{\"op\":\"add\",\"path\":\"/subjects/-\",\"value\":{\"kind\":\"ServiceAccount\",\"name\":\"me\",\"namespace\":\"ns\"}}]'"}, {Note: "Or, when only namespace-scoped, RoleBind yourself to cluster-admin within the namespace", Cmd: "kubectl create rolebinding pwn -n <ns> --clusterrole=cluster-admin --serviceaccount=<ns>:me"}, }, }, "read_secrets": { Title: "Secrets read access", Plain: template.HTML(`<p><code>get</code>/<code>list</code>/<code>watch</code> on Secrets in kube-system or cluster-wide reads the controller-manager, scheduler, and node-bootstrap tokens: every credential needed to act as the control plane.</p>`), Mitre: "T1552 — Unsecured Credentials", AttackerSteps: []AttackerStep{ {Note: "Dump every ServiceAccount token stored in kube-system", Cmd: "kubectl get secret -n kube-system -o json | jq -r '.items[] | select(.type==\"kubernetes.io/service-account-token\") | .data.token' | base64 -d"}, }, }, "nodes_proxy": { Title: "nodes/proxy → kubelet API", Plain: template.HTML(`<p>The <code>nodes/proxy</code> subresource forwards requests to the kubelet on each node. Combined with kubelet's <code>/exec</code> endpoint and a WebSocket verb mismatch, this becomes a primitive for executing commands inside any pod the kubelet can reach.</p>`), Mitre: "T1611 — Escape to Host", }, "csr_approve": { Title: "CSR self-approval to system:masters", Plain: template.HTML(`<p>The combination of <code>create</code> on <code>certificatesigningrequests</code> AND <code>update</code>/<code>patch</code> on <code>certificatesigningrequests/approval</code> at cluster scope lets the holder mint a kubelet-signed x509 client cert carrying any Subject DN they choose. Setting the Organization to <code>system:masters</code> produces a credential that the apiserver authorizes as cluster-admin regardless of RBAC.</p><p>This is a permanent backdoor primitive: the cert validity is whatever the signer applies (often a year), and revoking the original RBAC grant does not invalidate it — only a CA rotation does. The Kubernetes project lists this in RBAC Good Practices as a privilege-escalation risk on par with direct <code>impersonate</code>.</p>`), Mitre: "T1098.001 — Account Manipulation: Additional Cloud Credentials", AttackerSteps: []AttackerStep{ {Note: "Generate a private key and CSR locally with system:masters in the Subject", Cmd: "openssl req -new -newkey rsa:2048 -nodes -keyout admin.key -subj '/CN=attacker/O=system:masters' -out admin.csr"}, {Note: "Submit the CSR to the kube-apiserver-client signer", Cmd: "kubectl apply -f - <<EOF\napiVersion: certificates.k8s.io/v1\nkind: CertificateSigningRequest\nmetadata: {name: takeover}\nspec:\n request: $(base64 -w0 admin.csr)\n signerName: kubernetes.io/kube-apiserver-client\n usages: [client auth]\nEOF"}, {Note: "Self-approve the CSR", Cmd: "kubectl certificate approve takeover"}, {Note: "Extract the signed cert", Cmd: "kubectl get csr takeover -o jsonpath='{.status.certificate}' | base64 -d > admin.crt"}, {Note: "Use it as cluster-admin", Cmd: "kubectl --client-certificate=admin.crt --client-key=admin.key get secrets -A"}, }, }, "pod_host_escape": { Title: "Container escape to host", Plain: template.HTML(`<p>The pod is configured in a way that makes escaping to the underlying node trivial: <code>privileged: true</code>, <code>hostPID</code>, <code>hostNetwork</code>, or a sensitive <code>hostPath</code> mount (root, docker.sock, etc.). An attacker who controls the container reaches root on the node, then has access to every pod and kubelet credential on that node.</p>`), Mitre: "T1611 — Escape to Host", AttackerSteps: []AttackerStep{ {Note: "From inside the privileged pod, drop into PID 1's namespaces on the host", Cmd: "nsenter -t 1 -m -u -i -n -p -- /bin/sh"}, {Note: "Steal the kubelet's client cert (the node's identity to the API server)", Cmd: "cat /var/lib/kubelet/pki/kubelet-client-current.pem"}, {Note: "Pivot to other pods on the same node via the container runtime socket", Cmd: "crictl --runtime-endpoint unix:///run/containerd/containerd.sock ps"}, }, }, }
Techniques maps a privesc-action key (matching the Action strings in internal/analyzer/privesc/graph.go) to its educational content.
Functions ¶
func BuildHTMLData ¶
BuildHTMLData groups findings by module, category, namespace, subject, and resource, sorts each grouping by severity-weighted importance, and computes the derived visualizations (risk index, heatmap, attack graph, narratives) used by the HTML report.
func GlossaryKeyForResource ¶
func GlossaryKeyForResource(ref *models.ResourceRef) string
GlossaryKeyForResource picks the right Glossary key for a ResourceRef.
func GlossaryKeyForSubject ¶
func GlossaryKeyForSubject(ref *models.SubjectRef) string
GlossaryKeyForSubject picks the right Glossary key for a SubjectRef. Returns "" when there's no specific entry — the JS uses the entry-node Title in that case.
func GuessAdmissionSummaryPath ¶
GuessAdmissionSummaryPath returns the default admission-summary.json location for a findings file.
func GuessMetadataPath ¶
GuessMetadataPath returns the default scan-metadata.json location for a findings file in the same directory.
func GuessTruncationInfoPath ¶ added in v1.1.0
GuessTruncationInfoPath returns the default truncation-info.json location for a findings file.
func ReadAdmissionSummary ¶
func ReadAdmissionSummary(path string) (models.AdmissionSummary, error)
ReadAdmissionSummary decodes an admission-summary.json file. Missing-file errors propagate; callers should os.Stat first if absent files are acceptable.
func ReadFindings ¶
ReadFindings decodes a findings.json file written by Write into a slice of models.Finding.
func ReadMetadata ¶
func ReadMetadata(path string) (models.SnapshotMetadata, error)
ReadMetadata decodes a scan-metadata.json file back into a SnapshotMetadata value.
func ReadTruncationInfo ¶ added in v1.1.0
func ReadTruncationInfo(path string) (models.TruncationInfo, error)
ReadTruncationInfo decodes a truncation-info.json file. Missing-file errors propagate; callers should os.Stat first if absent files are acceptable.
func TechniqueKeyForFinding ¶
TechniqueKeyForFinding picks the right Techniques key for a Finding. Preference: the first hop's Action (privesc-PATH findings carry these); otherwise we map by RuleID prefix. Returns "" if no entry applies — the JS layer treats that as "no technique explainer".
func Truncate ¶ added in v1.1.0
func Truncate(findings []models.Finding, limit int, allFindings bool) ([]models.Finding, models.TruncationInfo)
Truncate caps an already-sorted findings slice to the top `limit` entries while preserving category diversity. The analyzer engine emits findings sorted by severity rank → score → ruleID; Truncate honors that order both as the input ranking signal and as the output ordering.
When the cap fires, findings are grouped by RiskCategory (preserving each category's internal order) and selected round-robin, so a single dominant category (e.g. privilege_escalation in clusters with sprawling RBAC) cannot crowd out smaller categories like data_exfiltration or defense_evasion. The selection is then re-sorted by the same global priority comparator so the visible order is still severity-first — diversity affects which findings appear, not the order they appear in.
When allFindings is true, limit <= 0, or len(findings) <= limit, the input is returned unchanged with a zero-value (Truncated=false) info — callers can use that as a "do nothing" gate without re-checking the flags. The underlying array is not mutated.
`--max-findings=0` is treated as "no cap" rather than "show zero findings" so an absent or zero flag never produces an empty report directory.
func Write ¶
func Write(outputDir string, formats []string, snapshot models.Snapshot, findings []models.Finding) ([]string, error)
Write emits the requested formats (html, json, csv, sarif) to outputDir along with the snapshot metadata side-file. It always writes metadata.json; unsupported format names return an error without partial cleanup.
func WriteAdmissionSummary ¶
func WriteAdmissionSummary(outputDir string, summary models.AdmissionSummary) (string, error)
WriteAdmissionSummary writes admission-summary.json alongside scan-metadata.json so the `kubesplaining report` subcommand can re-render the HTML banner without re-running analysis.
func WriteMetadata ¶
func WriteMetadata(outputDir string, metadata models.SnapshotMetadata) (string, error)
WriteMetadata writes scan-metadata.json alongside the findings so future `report` invocations can reconstruct snapshot context.
func WriteTruncationInfo ¶ added in v1.1.0
func WriteTruncationInfo(outputDir string, info models.TruncationInfo) (string, error)
WriteTruncationInfo writes truncation-info.json alongside scan-metadata.json so the `kubesplaining report` subcommand (and other consumers) can surface a "showing top N of M" banner without having to re-run analysis. Only call this when the cap actually fired; an empty (Truncated=false) sidecar would just be noise.
func WriteWithAdmission ¶
func WriteWithAdmission(outputDir string, formats []string, snapshot models.Snapshot, findings []models.Finding, admission models.AdmissionSummary, truncation models.TruncationInfo) ([]string, error)
WriteWithAdmission is Write plus an AdmissionSummary that is rendered into the HTML header banner, written as a sidecar admission-summary.json alongside the metadata file (so `kubesplaining report` can re-render the banner without re-running analysis), and embedded in the SARIF run properties for downstream CI consumers.
The TruncationInfo argument carries the --max-findings cap state. When truncation.Truncated is true, a banner is rendered in the HTML report, the info is embedded in the SARIF run properties, and a truncation-info.json sidecar is written so `kubesplaining report` can re-render the banner without re-running analysis. When false (the zero value), nothing is rendered.
func WriteWithOptions ¶ added in v1.1.0
func WriteWithOptions(outputDir string, formats []string, snapshot models.Snapshot, findings []models.Finding, admission models.AdmissionSummary, truncation models.TruncationInfo, opts Options) ([]string, error)
WriteWithOptions extends WriteWithAdmission with Options that control which HTML tab loads by default (used by --least-privilege-only) and the audit-log window summary rendered into the Least Privilege tab header.
Types ¶
type AttackGraph ¶
type AttackGraph struct {
Width int
Height int
Lanes [3]GraphLane
Nodes []GraphNode
Edges []GraphEdge
// Truncated reports whether the capability set was clipped to fit MaxCaps; the template
// uses Shown / TotalCaps to render a notice above the diagram explaining the slate.
Truncated bool
Shown int
TotalCaps int
}
AttackGraph is the 3-column flow diagram shown at the top of the report: entry points → abused capability → impact. All coordinates are precomputed in Go so the template can emit SVG without any layout logic.
type AttackerStep ¶
AttackerStep is one entry in an attacker walkthrough. Note is plain-language description (always shown). Cmd, when set, is the literal shell command — only Cmd lands in the copyable code block, so a reader who clicks Copy gets a runnable string, not prose.
type CategoryExplainer ¶
type CategoryExplainer struct {
Title string `json:"Title"`
Plain template.HTML `json:"Plain"`
Examples []string `json:"Examples,omitempty"`
}
CategoryExplainer is the impact-lane copy. Plain explains what the category means in concrete terms; Examples lists real-world manifestations a reader can picture.
type CategorySection ¶
type CategorySection struct {
Key string
CSSKey string
Label string
Summary Summary
Entries []TOCEntry // collapsed one-row-per-RuleID list for the Findings-tab TOC
}
CategorySection groups findings by RiskCategory for the "By Attack Theme" panel; it does not carry the findings themselves. CSSKey is the short heatmap-style key (privesc | lateral | exfil | infra | evasion) used for filter matching.
type ComplianceControlRow ¶ added in v1.1.0
type ComplianceControlRow struct {
Control string
Title string
URL string
// TopSeverity is the most-severe severity across the control's findings — drives the
// left-stripe color on the control card. The Summary fields would let the template
// re-derive this, but exposing it explicitly keeps the template free of nested
// conditionals.
TopSeverity models.Severity
Summary Summary
Findings []models.Finding
}
ComplianceControlRow is one control's rollup inside a framework view. Findings is the deduped list (no duplicate Finding.IDs) of every finding tagged with this control, sorted by severity then score for display.
type ComplianceFrameworkView ¶ added in v1.1.0
type ComplianceFrameworkView struct {
Slug string
Name string
ShortName string
URL string
// CSSKey is a short kebab-case identifier ("cis" / "nsa") that the template uses to
// switch framework-specific accent colors. Kept separate from Slug so the slug can
// stay as the public API identifier ("CIS-1.9") even if we tweak CSS classes.
CSSKey string
Summary Summary // severity tally across all findings tagged with this framework
Controls []ComplianceControlRow // one row per (framework, control) pair, sorted by severity then count
}
ComplianceFrameworkView is one framework's rollup — top-line counts plus the per-control rows.
type ComplianceSection ¶ added in v1.1.0
type ComplianceSection struct {
Total int // findings with at least one framework tag
UnmappedCount int // findings with zero framework tags
Frameworks []ComplianceFrameworkView // one per framework that has at least one control hit
}
ComplianceSection feeds the "Compliance Coverage" HTML tab. One ComplianceFrameworkView per registered framework, grouped by control. Rendered from compliance.Apply'd findings; if no findings carry the framework, the view is omitted from Frameworks. UnmappedCount is informational — how many findings in the report have no compliance mapping at all (e.g. internal-only rules).
type GlossaryEntry ¶
type GlossaryEntry struct {
Title string `json:"Title"`
Short string `json:"Short"`
Long template.HTML `json:"Long"`
DocURL string `json:"DocURL,omitempty"`
}
GlossaryEntry teaches one Kubernetes concept that appears as an entry-point or capability node in the attack graph. Short feeds tooltips; Long renders into the side-panel detail view.
type GraphEdge ¶
type GraphEdge struct {
ID string // stable ID of the form "edge-<fromNodeID>-<toNodeID>" — used by the JS layer.
Class string // crit | high | med
D string
}
GraphEdge is a rendered bezier path in the attack graph.
type GraphEdgeDetail ¶
type GraphEdgeDetail struct {
ID string `json:"ID"`
From string `json:"From"`
To string `json:"To"`
Class string `json:"Class"` // crit | high | med
TechniqueKey string `json:"TechniqueKey,omitempty"`
ActionLabel string `json:"ActionLabel,omitempty"` // human-readable verb (e.g., "abuses", "leads to")
}
GraphEdgeDetail describes one bezier edge for the JS layer.
type GraphLane ¶
GraphLane labels one of the three columns at the top of the attack graph. X is the lane's left edge (matches the X of nodes in that lane). LabelX/LabelW position the centered header pill — they are pre-computed so the template can emit `text-anchor=middle` and snug the pill width to its label.
type GraphNode ¶
type GraphNode struct {
ID string
Kind string
X int
Y int
Width int
Height int
SevClass string // crit | high | med
AriaLabel string
Lines []GraphTextLine
}
GraphNode is a rendered rectangle in the attack graph. Kind is "entry" | "capability" | "impact". Text content lives in Lines so the template can emit pre-wrapped <text> elements without doing any layout work — Height is grown to match the wrapped line count. AriaLabel is the per-node accessible name announced by screen readers; without it every <g> would share a generic label like "Entry point" / "Abused capability" / "Impact" and be indistinguishable while tabbing.
type GraphNodeDetail ¶
type GraphNodeDetail struct {
ID string `json:"ID"`
Kind string `json:"Kind"` // entry | capability | impact
Severity string `json:"Severity"` // crit | high | med
Title string `json:"Title"`
Subtitle string `json:"Subtitle,omitempty"`
GlossaryKey string `json:"GlossaryKey,omitempty"` // entry nodes
RuleID string `json:"RuleID,omitempty"` // capability nodes
TechniqueKey string `json:"TechniqueKey,omitempty"` // capability nodes
Description string `json:"Description,omitempty"` // capability nodes
Remediation string `json:"Remediation,omitempty"` // capability nodes
References []string `json:"References,omitempty"` // capability nodes
Hops []HopView `json:"Hops,omitempty"` // privesc-PATH capability nodes
EdgeIDs []string `json:"EdgeIDs,omitempty"` // edges incident on this node
EntryKind string `json:"EntryKind,omitempty"` // Subject | Resource (entry filter)
RiskCategory string `json:"RiskCategory,omitempty"` // impact nodes
}
GraphNodeDetail is the per-node detail payload — title, severity, glossary key, rule description, remediation, references, hop chain, and the IDs of edges this node connects to. Wired up to the SVG <g data-node-id> elements client-side.
type GraphPayload ¶
type GraphPayload struct {
Nodes []GraphNodeDetail `json:"Nodes"`
Edges []GraphEdgeDetail `json:"Edges"`
Glossary map[string]GlossaryEntry `json:"Glossary"`
Techniques map[string]TechniqueExplainer `json:"Techniques"`
Categories map[string]CategoryExplainer `json:"Categories"`
}
GraphPayload is the parallel detail-only payload that the inline JSON block embeds for the JS interactivity layer. It is kept separate from AttackGraph (which stays cosmetic — only what the SVG template needs) so multi-paragraph educational HTML doesn't bloat per-element attributes.
type GraphTextLine ¶
type GraphTextLine struct {
Class string // CSS class: node-title | node-sub | node-meta | rule-id | impact-title
Text string
OffsetX int
OffsetY int
}
GraphTextLine is one rendered <text> baseline inside a node, positioned relative to the node origin.
type HeatmapCategory ¶
type HeatmapCategory struct {
Key string // privesc | lateral | exfil | infra | evasion
Label string
}
HeatmapCategory is a column header in the heatmap.
type HeatmapCell ¶
type HeatmapCell struct {
Count int
CatClass string // privesc | lateral | exfil | infra | evasion
Level int // 0..5
Resource string // resourceDisplay() of the row, copied for click-handler convenience
}
HeatmapCell is one cell of the Resource × Category heatmap. Level is 0..5 for CSS intensity class. Resource is duplicated on every cell to keep the template flat — clicking a cell needs both the row's resource and the column's category to set Findings-tab filter chips.
type HeatmapRow ¶
type HeatmapRow struct {
Label string
SevClass string // crit | high | med — derived from the highest-severity finding on this resource
SevLabel string // CRIT | HIGH | MED
Total int
Cells []HeatmapCell
}
HeatmapRow is one row of the Resource × Category heatmap.
type HeroChainCard ¶ added in v1.1.0
type HeroChainCard struct {
Title string
Severity models.Severity
Score float64
Sink string // e.g. "cluster_admin", "kube_system_secrets"
Summary string // one-line plain-English narrative
Hops []models.EscalationHop
RuleID string
Anchor string // anchor target inside the Findings tab
}
HeroChainCard is one above-the-fold "critical attack chain" entry rendered at the very top of the HTML report (STRATEGY.md:151, plan slot W1 #4). Each card carries the chain's originating Subject, the sink it reaches (cluster_admin, kube_system_secrets, ...), the full hop list, and a one-line plain-English summary. Populated by buildHeroChains in summary.go; the Wave 0 stub returns nil so the {{ if .HeroChains }} template gate keeps the section absent until Wave 1 fills it in.
type HopView ¶
type HopView struct {
Step int `json:"Step"`
From string `json:"From"`
To string `json:"To"`
Action string `json:"Action"`
TechniqueKey string `json:"TechniqueKey"`
Permission string `json:"Permission,omitempty"`
Gains string `json:"Gains,omitempty"`
}
HopView is one walkthrough step for a privesc-PATH finding — pre-rendered so the JS does no derivation. Step is 1-indexed for display.
type Hotspot ¶
Hotspot is a generic "label + summary" entry used in the top-namespaces/subjects/resources sidebar lists.
type LPClusterAdminRow ¶ added in v1.1.0
type LPClusterAdminRow struct {
Binding string
SubjectKind string
SubjectNs string
SubjectName string
IsSystem bool
}
LPClusterAdminRow is one subject directly bound to the built-in cluster-admin ClusterRole. The "Subjects bound to cluster-admin" table is an inventory view sitting alongside the actionable narrowing tables: operators get a single scannable list of every identity that holds full cluster control so they can decide which entries are legitimate (built-in system:* subjects, the break-glass group) and which need to be pared back. IsSystem drives a softer visual treatment for the rows that are expected.
type LPRoleVerbRow ¶ added in v1.1.0
type LPRoleVerbRow struct {
RuleID string
Severity models.Severity
Role string // "<Kind>/<name>" e.g. "Role/prod-deployer"
Subject string // compact "ns/name" or "name"
UsedVerbs template.HTML
UnusedVerbs template.HTML
Action template.HTML
FindingID string
}
LPRoleVerbRow is a single row in the "Role -> unused verbs" summary table. Built from findings whose RuleID describes verb-level narrowing opportunities (UNUSED-VERB, UNUSED-RULE with verb list, WILDCARD-USED-PARTIAL). UsedVerbs and UnusedVerbs are pre-rendered HTML (one entry per line, verbs/resources wrapped in <code>) so the template can drop them straight into the cells - showing both sides lets the operator sanity-check the narrowing recommendation against what the workload actually does. Action describes the concrete edit ("drop verbs X, Y" / "replace verbs: [*] with...") so the row is actionable without expanding the card.
type LPUnusedResourceRow ¶ added in v1.1.0
type LPUnusedResourceRow struct {
RuleID string
Severity models.Severity
Resource string // "<Kind>/<name>" e.g. "ClusterRole/cluster-admin"
Subject string // compact "ns/name" or "name"
Action template.HTML
Why string
FindingID string // anchor target for "jump to detail"
}
LPUnusedResourceRow is a single row in the "Unused RBAC resources" summary table at the top of the Least Privilege tab. Built from findings whose RuleID describes a whole Role/Binding as unused or stale (UNUSED-ROLE, UNUSED-RULE, STALE-*, OVERBROAD-*). Resource holds the kind+name display string (e.g. "Role/cleanup-helper") so the table doesn't need a separate Kind column - "RBACRule" is not a real Kubernetes kind, just an internal label the rbac analyzer uses, and surfacing it confused operators.
Action is the actionable "what should I delete?" answer rendered as HTML so the binding name can sit inside a <code> chip. The plain-English explanation lives on Why (still a string).
type LeastPrivilegeFindingCard ¶ added in v1.1.0
LeastPrivilegeFindingCard wraps a single finding with extracted per-tab presentation data. SuggestedRoleYAML is pulled out of Evidence so the template can render it as a proper <pre><code> block instead of inline-code spans bleeding through markdown.
type LeastPrivilegeGroup ¶ added in v1.1.0
type LeastPrivilegeGroup struct {
SubjectLabel string // "ServiceAccount default/builder" or "Group system:masters"
SubjectKind string
SubjectName string
SubjectNs string
Summary Summary
Cards []LeastPrivilegeFindingCard
}
LeastPrivilegeGroup is one (subject) bucket inside the Least Privilege tab. Cards are pre-sorted by severity then score so the worst opportunity surfaces first per subject.
type LeastPrivilegeSection ¶ added in v1.1.0
type LeastPrivilegeSection struct {
Total int
WindowStart string // pre-formatted "YYYY-MM-DD", empty when no audit data
WindowEnd string
WindowDays int
EventsProcessed int
NonSAUsernames int // unique non-ServiceAccount callers in the window (humans, groups, system:node:*); surfaces an explicit "out of scope" note in the tab
HasAuditData bool
UnusedResources []LPUnusedResourceRow
RoleVerbMap []LPRoleVerbRow
ClusterAdminInventory []LPClusterAdminRow // inventory of every subject bound to the built-in cluster-admin ClusterRole
Groups []LeastPrivilegeGroup
}
LeastPrivilegeSection feeds the "Least Privilege" HTML tab. It surfaces every finding whose RuleID is part of leastprivilege.TabRuleIDPrefixes: both the new audit-driven rules (UNUSED-*, WILDCARD-USED-PARTIAL-*) and the pre-existing static cleanup rules (STALE-*, OVERBROAD-*). Two summary tables sit above the per-subject cards so an operator can scan "what's unused" and "what verbs to drop" without expanding every card; the cards carry the full prose and remediation YAML for the picked target.
type ModuleSection ¶
type ModuleSection struct {
ID string // slug used as an anchor ID
Label string
Summary Summary
Findings []models.Finding
RuleGroups []RuleGroup
Entries []TOCEntry // collapsed one-row-per-RuleID list for the Findings-tab TOC
}
ModuleSection groups findings by the module that emitted them for per-module display in the HTML report. RuleGroups is the rendering view: findings are bucketed by RuleID so the template can render shared rule-level content (title, MITRE, References) once and list per-subject instances below. Findings is preserved for callers that want the flat list (TOC builders, snapshot exports).
type NarrativeCard ¶
type NarrativeCard struct {
Title string
Severity string // CRITICAL / HIGH
Steps []NarrativeStep
RuleIDs []string
}
NarrativeCard is an auto-detected attack chain — a short plain-English walkthrough of how specific findings compose into a real-world exploit path. Generated by the narrative detectors, not hand-authored per cluster.
type NarrativeStep ¶
NarrativeStep is one numbered step in a NarrativeCard. Items is optional; when non-empty the template renders a bulleted sublist under Text — used to surface long subject/resource lists (e.g. thousands of service accounts) without ballooning the card width.
type Options ¶ added in v1.1.0
type Options struct {
// DefaultTab is the data-active-tab value the HTML report initializes to. Empty
// preserves the existing default ("attack"). Currently the only non-empty caller is
// scan.go's --least-privilege-only mode, which sets "leastprivilege".
DefaultTab string
// LeastPrivilegeOnly hides the other three tab buttons + the recon panel when
// true. Set by scan.go's --least-privilege-only mode. Without this, an operator in
// focus mode would still see Attack Paths / Risk Overview / Findings buttons
// they aren't using.
LeastPrivilegeOnly bool
// UsageInfo is a snapshot of the usage index's window/event metadata, captured for
// the report layer. nil means no audit data was supplied; the tab's empty-state
// shows a help block instead of a window summary.
UsageInfo *UsageInfo
}
Options bundles CLI-supplied render-time settings. Zero value = legacy behavior: DefaultTab "" preserves the report's existing initial tab (attack); UsageInfo nil hides the audit-log header on the Least Privilege tab; LeastPrivilegeOnly false leaves all four tab buttons visible.
type Recon ¶
type Recon struct {
Shape ReconShape
Ownership ReconOwnership
Surface ReconSurface
Guardrails ReconGuardrails
Provenance ReconProvenance
HeadlineChips []ReconChip
}
Recon is the rendered cluster-reconnaissance panel: a four-section view plus a nested provenance section, with a row of headline chips shown on the closed disclosure summary so even the collapsed state hints at what's inside.
type ReconChip ¶
ReconChip is a small headline pill rendered in the collapsed disclosure summary. Tone is "ok" | "warn" | "danger"; the template maps each to a CSS class.
type ReconGuardrails ¶
type ReconGuardrails struct {
NetworkPolicies int
NamespacesProtected int
NamespacesUnprotected int
PSAEnforce map[string]int // mode → namespace count (e.g. "restricted" → 4)
Engines []string // "Kyverno", "Gatekeeper", "VAP"
DefaultSATokenMounts int
}
ReconGuardrails — what is/isn't protecting the cluster.
type ReconOwnership ¶
type ReconOwnership struct {
ClusterAdmins ReconSubjectList
WildcardVerbSubjects ReconSubjectList
SecretReaders ReconSubjectList
PrivescToAdminCount int
PrivescToAdminAnchor string // "finding-KUBE-PRIVESC-PATH-CLUSTER-ADMIN" or ""
NodeEscapeCount int
NodeEscapeAnchor string
}
ReconOwnership — who already owns the cluster.
type ReconProvenance ¶
type ReconProvenance struct {
CollectorIdentity string
PermissionsAvailable []string
PermissionsMissing []string
CollectionWarnings []string
NamespacesScanned []string
CollectionDurationSec float64
}
ReconProvenance — collection-time meta. Tucked behind a nested disclosure since it's operational metadata rather than a recon "wow" fact.
type ReconResourceList ¶
type ReconResourceList struct {
Total int
Sample []string // up to 3 "namespace/name" labels
MoreCount int
}
ReconResourceList is the parallel pattern for Pod/Service object samples.
type ReconShape ¶
type ReconShape struct {
Distribution string // "EKS v1.28.5" | "v1.28.5" | ""
CloudLabel string // "AWS / us-east-1" | "On-prem / unknown"
NodeCount int
ArchBreakdown string // "10×amd64, 2×arm64"
OSImage string // most common Status.NodeInfo.OSImage
KubeletVersions []string // distinct, "vX.Y.Z ×N" rows (sorted descending by count)
RuntimeVersions []string // same shape for ContainerRuntimeVersion
NamespaceCount int
PodCount int
SACount int
APIHost string // host portion of APIServerURL
APIReachability string // "private" | "public" | "loopback" | "linklocal" | "unknown"
}
ReconShape — "where am I, how big is it" facts.
type ReconSubjectList ¶
type ReconSubjectList struct {
Total int
Sample []string // up to 3 "Kind/[Namespace/]Name" labels, sorted alphabetically
MoreCount int // Total - len(Sample); zero when nothing was elided
Anchor string // optional Findings-tab deep link
}
ReconSubjectList is the count + sample-of-three pattern for RBAC subjects.
type ReconSurface ¶
type ReconSurface struct {
LoadBalancers ReconResourceList
NodePorts int
ExternalIPs int
HostNetworkPods ReconResourceList
PrivilegedPods ReconResourceList
MutatingWebhooks int
OutOfClusterWebhooks int
}
ReconSurface — what an outside scanner sees.
type RuleGroup ¶
type RuleGroup struct {
RuleID string
RuleTitle string // subject-neutralized title for the rule header
TopSeverity models.Severity
MaxScore float64
MinScore float64
InstanceCount int
Anchor string
MitreTechniques []models.MitreTechnique
LearnMore []models.Reference
References []string
Findings []models.Finding
}
RuleGroup collapses all findings sharing a RuleID into a single rendering unit. Each group renders once as a "rule card" with shared educational content (rule title, MITRE techniques, References) at the top, and one collapsed <details> instance per affected subject below.
TopSeverity is the highest severity across instances; MaxScore/MinScore bracket the score range. Anchor matches AnchorByID for the first instance — narrative chips and TOC rows already point here, so the rule card lands on existing deep-links without churn.
type ScoringBreakdown ¶ added in v1.1.0
type ScoringBreakdown struct {
Score float64
HasFactors bool
Base float64
Exploitability float64
BlastRadius float64
ChainModifier float64
}
ScoringBreakdown is the per-finding scoring tooltip payload (STRATEGY.md:155, plan slot W1 #6). Returned by buildScoringTooltip in summary.go. When HasFactors is false (the finding's Score was hand-picked by the analyzer rather than composed from Factors), the template falls back to showing just the final Score.
func BuildScoringTooltip ¶ added in v1.1.0
func BuildScoringTooltip(f models.Finding) ScoringBreakdown
BuildScoringTooltip is the Wave 0 helper that converts a finding into the ScoringBreakdown surfaced in the HTML score tooltip (STRATEGY.md:155, plan slot W1 #6). When ScoreFactors is nil (the legacy hand-picked-score path), the returned breakdown only carries the final Score and HasFactors is false, so the template degrades gracefully to a plain "score: 7.4" tooltip.
Exported so the W1 #6 worktree can wire it from evidence_render.go (or any future package) without re-declaring the conversion logic; today it is only invoked from the template helper map registered in writers.go.
type SubjectCapabilityCard ¶ added in v1.1.0
type SubjectCapabilityCard struct {
SubjectKind string
SubjectName string
SubjectNs string
SubjectLabel string // pre-rendered display string e.g. "ServiceAccount/default/builder"
EffectiveVerbs []string
EffectiveRules []string // pre-rendered "verbs on resources@apiGroup" strings
Bindings []string // pre-rendered "ClusterRoleBinding/<name> -> ClusterRole/<role>" strings
PrivescPaths []string // chain summaries originating from this subject
ChainAmplified bool // true iff PrivescPaths is non-empty
HighestSeverity models.Severity
}
SubjectCapabilityCard is one "per-ServiceAccount capability" entry surfacing aggregated EffectiveRules plus any privesc paths originating from that subject (STRATEGY.md:32, plan slot W1 #7). The Cloudsplaining-equivalent "what can this principal actually do" view, scoped to RBAC subjects rather than IAM principals. Populated by buildPerSubjectCapabilities in leastprivilege_section.go.
Bindings is the deduped list of (Cluster)Role names the subject is bound to (rendered as "RoleBinding -> Role/<name>" / "ClusterRoleBinding -> ClusterRole/<name>"). ChainAmplified is true when at least one privesc path originates from this subject; the template surfaces a badge so operators can prioritize subjects with exploitable chains over those whose grants are merely broad.
type Summary ¶
Summary tallies findings by severity bucket, reused at the global, module, category, and hotspot levels.
func BuildSummary ¶
BuildSummary counts findings by severity to produce a Summary.
type TOCEntry ¶
type TOCEntry struct {
RuleID string
Title string
Severity models.Severity
Anchor string
Count int
}
TOCEntry is one row in the Findings-tab table-of-contents — a single rule, the highest severity it fired at within its group, and the number of finding instances it produced. Anchor points at the deterministic per-rule anchor (id="finding-<RuleID>") that the first occurrence of each rule already carries on its <article> card.
type TechniqueExplainer ¶
type TechniqueExplainer struct {
Title string `json:"Title"`
Plain template.HTML `json:"Plain"`
Mitre string `json:"Mitre,omitempty"`
AttackerSteps []AttackerStep `json:"AttackerSteps,omitempty"`
}
TechniqueExplainer describes one attacker technique in plain language. Plain renders as HTML in the side-panel; AttackerSteps is rendered as an ordered list ("here is what an attacker would actually run, in order").
type TopFix ¶ added in v1.1.0
type TopFix struct {
Rank int
Action string // e.g. "Delete ClusterRoleBinding cluster-admin-builder"
ScoreImpact float64 // sum of finding scores that this single fix would resolve
FindingsCount int
Subjects []string
RuleIDs []string
}
TopFix is one "Top 5 fixes" row computed from finding count × score reduction (STRATEGY.md:162, plan slot W1 #5). Wave 1 will group findings by Subject, sum scores, and pick the highest-impact role / binding deletion. Populated by buildTopFixes in summary.go; Wave 0 stub returns nil so the {{ if .TopFixes }} template gate keeps the section absent.
type UsageInfo ¶ added in v1.1.0
type UsageInfo struct {
WindowStart time.Time
WindowEnd time.Time
EventsProcessed int
EventsSkipped int
NonSAUsernames int
}
UsageInfo carries the audit-log window summary into the report layer. We don't pass the full *usage.UsageIndex because (a) the report has no need for the per-subject map and (b) keeping the report-side type small means changing the index internals doesn't ripple through the renderer.
func UsageInfoFrom ¶ added in v1.1.0
func UsageInfoFrom(idx *usage.UsageIndex) *UsageInfo
UsageInfoFrom captures the window metadata from idx. Returns nil for a nil index so scan.go can pass through both audit-log and no-audit-log runs uniformly.
func (*UsageInfo) WindowDays ¶ added in v1.1.0
WindowDays returns the integer day-count covered by [WindowStart, WindowEnd]. Used by the HTML tab header.