report

package
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 16, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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.

View Source
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/&lt;pid&gt;/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.

View Source
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

func BuildHTMLData(snapshot models.Snapshot, findings []models.Finding) htmlReportData

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

func GuessAdmissionSummaryPath(findingsPath string) string

GuessAdmissionSummaryPath returns the default admission-summary.json location for a findings file.

func GuessMetadataPath

func GuessMetadataPath(findingsPath string) string

GuessMetadataPath returns the default scan-metadata.json location for a findings file in the same directory.

func GuessTruncationInfoPath added in v1.1.0

func GuessTruncationInfoPath(findingsPath string) string

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

func ReadFindings(path string) ([]models.Finding, error)

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

func TechniqueKeyForFinding(f models.Finding) string

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

type AttackerStep struct {
	Note string `json:"Note,omitempty"`
	Cmd  string `json:"Cmd,omitempty"`
}

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

type GraphLane struct {
	X      int
	Label  string
	LabelX int
	LabelW int
}

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

type Hotspot struct {
	Label   string
	Summary Summary
}

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

type LeastPrivilegeFindingCard struct {
	Finding           models.Finding
	SuggestedRoleYAML string
}

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

type NarrativeStep struct {
	Text  string
	Items []string
}

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

type ReconChip struct {
	Label string
	Value string
	Tone  string
}

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

type Summary struct {
	Total    int
	Critical int
	High     int
	Medium   int
	Low      int
	Info     int
}

Summary tallies findings by severity bucket, reused at the global, module, category, and hotspot levels.

func BuildSummary

func BuildSummary(findings []models.Finding) Summary

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

func (u *UsageInfo) WindowDays() int

WindowDays returns the integer day-count covered by [WindowStart, WindowEnd]. Used by the HTML tab header.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL