saga

package
v0.99.0 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package saga defines the Draugr descriptor ("Saga") — the declarative account of an application's security surface (repositories, images, hosts, infrastructure) plus the controller configuration that drives a scan. It also handles parsing, validation, env-var substitution, and assembly of distributed meta-sources.

See docs/ARCHITECTURE.md and docs/naming.md.

Index

Constants

View Source
const (
	// FeedSourceCache reads the cache and never reaches the network.
	FeedSourceCache = "cache"
	// FeedSourceAuto reads the cache, fetching when it is missing or stale.
	FeedSourceAuto = "auto"
)

ExploitabilitySources are the values KEV and EPSS accept besides a file path.

View Source
const (
	// VEXNotAffected claims the vulnerability is present but cannot affect this product.
	VEXNotAffected = "not_affected"
	// VEXAffected admits the product is affected and something should be done about it.
	VEXAffected = "affected"
	// VEXFixed claims a remediation has already been applied.
	VEXFixed = "fixed"
	// VEXUnderInvestigation says the question is open. Draugr emits this for findings nobody has
	// triaged; it is not a value an exclusion may declare.
	VEXUnderInvestigation = "under_investigation"
)

The VEX statuses a suppression may declare.

View Source
const FragmentSchemaURL = schemaBaseURL + "/draugr.saga-fragment.schema.json"

FragmentSchemaURL is the published fragment schema tracking the latest release.

View Source
const Indent = 2

Indent is how many spaces a Saga is written with.

Shared because several commands write the same file — a survey creates it, `classify` sets exposure and criticality in place, `validate --resolved` prints it merged — and each one that picks its own indent reindents the whole document as a side effect of changing two fields. A two-field edit that rewrites sixty lines is a diff nobody can review, and the encoder's default is not a decision anyone made.

View Source
const SchemaURL = schemaBaseURL + "/draugr.saga.schema.json"

SchemaURL is the schema that tracks the latest release. Use it to follow along with new Draugr versions; use SchemaURLFor to stay matched to a specific one.

Variables

BuiltByValues are the values builtBy accepts.

Criticalities lists the valid criticality levels, most to least critical.

Exposures lists the valid exposure levels, most to least exposed.

View Source
var FragmentSchemaJSON []byte

FragmentSchemaJSON is the Saga fragment's JSON Schema, embedded for the same reason.

A separate document because a fragment is a different shape: it has no `release:`, and it may not carry policy. Validating one against the Saga's schema would report a missing release on every valid fragment, which is why the two file types are distinguishable by name.

View Source
var GateThresholds = sarif.Severities

GateThresholds lists the severity bands a gate may be set to, most to least severe.

The bands the report prints, so a threshold reads the same as the counts beside it. The SARIF levels a gate used to take — error, warning, note — are still accepted and mapped onto the band each one means, so a descriptor written against the older vocabulary keeps working.

OperatedByValues are the values operatedBy accepts.

View Source
var Priorities = []string{"P1", "P2", "P3", "P4"}

Priorities lists the priority bands a report may be narrowed to, most to least urgent.

Spelled here rather than taken from pkg/prioritization, which imports this package — the descriptor is a leaf, and a cycle to share four constants is a poor trade.

SBOMFormats lists the valid SBOM document formats.

SBOMScopes lists the valid scopes.

View Source
var SchemaJSON []byte

SchemaJSON is the Saga's JSON Schema, embedded so the binary always carries the schema it actually enforces. That makes `draugr schema` exact — no network, no version guessing — and is what lets an air-gapped or pinned setup validate against precisely this build.

View Source
var VEXJustifications = []string{
	"component_not_present",
	"vulnerable_code_not_present",
	"vulnerable_code_not_in_execute_path",
	"vulnerable_code_cannot_be_controlled_by_adversary",
	"inline_mitigations_already_exist",
}

VEXJustifications is VEX's fixed vocabulary for why a product is not affected.

VEXStatuses are the statuses an exclusion may declare.

Functions

func AnnotateExposures added in v0.81.0

func AnnotateExposures(data []byte, reasons map[string]string) ([]byte, error)

AnnotateExposures adds a trailing comment to each named component's `exposure`, saying what the value was read from.

A proposed exposure and a decided one are the same three characters in a file, and the value decides whether a finding is reported as P1 or P3. The survey says which ones it guessed on the way out — but that is a terminal that scrolls, and the review happens later, in an editor, by someone who may not have run the command. The reason has to be where the value is:

exposure: public   # an Ingress routes into it

Only components in reasons are touched, so a value somebody decided is left without a comment rather than described as a guess. Written through the YAML node tree, so nothing else in the document moves.

func GlobMatch added in v0.69.0

func GlobMatch(pattern, rel string) bool

GlobMatch reports whether rel matches pattern, with `**` crossing separators.

One glob dialect for the whole descriptor: `paths:`, `ignore:` and `fragments:` all mean the same thing by the same pattern. path.Match handles a single segment; `**` is split on and each side matched around it, so `**/testdata/**` and `vendor/**` both behave the way the person writing them expects.

Lives here rather than beside either caller because the dialect is part of the Saga language — it is what a descriptor means by a pattern, so it belongs with the rest of the descriptor's definition. Two copies would be two dialects the moment one of them was fixed.

func Marshal added in v0.98.0

func Marshal(doc any) ([]byte, error)

Marshal renders a descriptor as YAML, at the indentation Draugr writes.

Here rather than beside any one caller, because the indent is the whole point and every writer has to agree on it. yaml.Marshal's default is four spaces, which is not a choice anybody made — and a file written with it is reindented end to end the first time something else edits a field in it, turning a one-line change into a whole-file diff nobody can review.

func Merge added in v0.69.0

func Merge(model *Model, frag Fragment)

Merge folds a fragment into a model: components by name, exclusions appended.

Components upsert and union rather than replace, so two fragments describing one component — a shared one naming its repository and a per-product one adding its image — end up as a single component with both. That is the same merge a Surveyor's fragment goes through.

func NarrowsScope added in v0.74.0

func NarrowsScope(model *Model, frag Fragment) []string

NarrowsScope reports whether merging frag into model would have narrowed an infrastructure target's namespaces, had the wider scope not been kept.

Exported so the command that merges can say so. The merge itself keeps the wider scope, which is the safe answer and also the surprising one: somebody who passed `--namespace` is entitled to know their flag did not reach the descriptor.

func NarrowsScopeIn added in v0.81.0

func NarrowsScopeIn(components []Component, frag Fragment) []string

NarrowsScopeIn is NarrowsScope against a bare component list, for a document that is not a Model — a fragment merges into a fragment, and the question is the same either way.

func SchemaURLFor added in v0.33.0

func SchemaURLFor(version string) string

SchemaURLFor returns the published schema for a specific Draugr version, so an editor validates a Saga against the same rules the installed binary applies. Unreleased builds ("dev", or an empty version) have no published copy, so they fall back to the latest.

func ValidVEXJustification added in v0.68.0

func ValidVEXJustification(j string) bool

ValidVEXJustification reports whether j is one of VEX's not_affected justifications.

func ValidVEXStatus added in v0.68.0

func ValidVEXStatus(s string) bool

ValidVEXStatus reports whether s is a status an exclusion may declare.

func WriteClassifications added in v0.6.0

func WriteClassifications(data []byte, class map[string]Classification) ([]byte, error)

WriteClassifications sets each named component's exposure and criticality in the raw Saga bytes and returns the updated document. It operates on the parsed YAML nodes, so comments and ${{ VAR }} tokens are preserved (values are not substituted); indentation is normalized to two spaces. Components not present in class are left untouched. New keys are inserted right after the component's name for readability; existing values are updated in place.

Types

type BuiltBy added in v0.96.0

type BuiltBy string

BuiltBy says who publishes an image.

const (
	// BuiltBySelf is the default: this team builds it, so a package inside it is theirs to
	// upgrade.
	BuiltBySelf BuiltBy = "self"
	// BuiltByUpstream is an image this team runs and somebody else publishes. Upstream rather
	// than "vendor": the publisher is as often an open-source project as a company.
	BuiltByUpstream BuiltBy = "upstream"
)

Who builds an image.

func (BuiltBy) Valid added in v0.96.0

func (b BuiltBy) Valid() bool

Valid reports whether the value is one Draugr defines.

type Classification added in v0.6.0

type Classification struct {
	Exposure    Exposure
	Criticality Criticality
}

Classification is a component's risk tags.

type Component

type Component struct {
	Name           string                        `yaml:"name"`
	Labels         map[string]string             `yaml:"labels,omitempty"`
	Exposure       Exposure                      `yaml:"exposure,omitempty"`
	Criticality    Criticality                   `yaml:"criticality,omitempty"`
	Repositories   []Repository                  `yaml:"repositories,omitempty"`
	Images         []Image                       `yaml:"images,omitempty"`
	Hosts          []Host                        `yaml:"hosts,omitempty"`
	Infrastructure []Infrastructure              `yaml:"infrastructure,omitempty"`
	Controllers    map[string]ControllerSettings `yaml:"controllers,omitempty"`
}

Component is one logical part of an application: its repositories, images, hosts, and infrastructure, plus optional per-component controller overrides and risk classification.

func UpsertComponent added in v0.69.0

func UpsertComponent(components []Component, comp Component) []Component

UpsertComponent appends comp, or unions its surface into an existing same-named one.

The overlay that makes split descriptors work: a shared fragment naming a component's repository and a per-product fragment adding its image end up as one component holding both.

Scalars come from the component already present, so the first description of a component wins on name, classification and labels. That is why a resolution starts from the root descriptor — it keeps the file someone opened authoritative about how exposed a component is, rather than letting a fragment merged later quietly reclassify it.

func (Component) ControllerEnabled

func (comp Component) ControllerEnabled(name string, project Config) bool

ControllerEnabled reports whether the named controller is enabled for this component, falling back to the project-level setting when the component has no override.

type Config

type Config struct {
	Controllers map[string]ControllerSettings `yaml:"controllers,omitempty"`
	// Reports are the report formats to render on a scan (e.g. json, sarif, markdown, html).
	// Publishers deliver every rendered report to a destination.
	Reports []ReportConfig `yaml:"reports,omitempty"`
	// Publishers are the destinations that rendered reports are delivered to.
	Publishers []PublisherConfig `yaml:"publishers,omitempty"`
	// Gate tunes the pass/fail thresholds. Policy belongs in the descriptor rather than in a
	// flag every pipeline has to remember to pass.
	Gate *GateConfig `yaml:"gate,omitempty"`
	// Exclude suppresses findings that match, with a stated reason. Suppressed findings are
	// still reported — they just stop counting toward the verdict.
	Exclude []ExcludeRule `yaml:"exclude,omitempty"`
	// VEX names the author and product for a generated VEX document (`--report vex`). Optional;
	// without it Draugr falls back to the release, which produces a valid document rather than a
	// publishable one.
	VEX *VEXConfig `yaml:"vex,omitempty"`
	// SBOM turns on Software Bill of Materials generation for this project's repositories and
	// images. It is evidence rather than a control: an SBOM is an inventory, it finds nothing,
	// and it never affects the verdict.
	SBOM *SBOMConfig `yaml:"sbom,omitempty"`
	// Exploitability raises a finding's severity by real-world signals — CISA KEV and FIRST
	// EPSS — before it is ranked, so "what to fix first" reflects what is being exploited
	// rather than only what could be.
	Exploitability *ExploitabilityConfig `yaml:"exploitability,omitempty"`
	// AllowEffects acknowledges scanner effects that would otherwise stop a run — the kinds a
	// scanner declares when it does more to a target than read it ("mutate", "privilege").
	//
	// In the descriptor rather than only a flag, because it is a decision about what may be
	// done to your systems: reviewed in a pull request, and applied identically by every
	// pipeline instead of remembered by whoever wrote the workflow.
	AllowEffects []string `yaml:"allowEffects,omitempty"`
}

Config holds global, per-controller configuration. Each controller's config tree is free-form (scanner-specific keys live under it); use ControllerEnabled to read the common "enabled" flag.

func (Config) ControllerEnabled

func (c Config) ControllerEnabled(name string) bool

ControllerEnabled reports whether the named controller is enabled at the project level. A controller is enabled when its config entry exists and its "enabled" key is not explicitly false. Absent entries are considered disabled.

type ControllerSettings

type ControllerSettings map[string]any

ControllerSettings is a free-form configuration tree for one controller.

type Criticality added in v0.5.0

type Criticality string

Criticality is a component's business-criticality level — the operational impact if it fails or is compromised. It is the other axis of risk prioritization and is always human-declared, as it cannot be inferred from code. The levels are a fixed ladder with org-defined meaning. See docs/concepts.md (prioritization).

const (
	CriticalityCritical   Criticality = "critical"   // failure causes outage or data loss
	CriticalityImportant  Criticality = "important"  // degraded functionality, no immediate outage
	CriticalitySupporting Criticality = "supporting" // limited operational impact
)

Criticality levels, from most to least critical.

func (Criticality) Valid added in v0.5.0

func (c Criticality) Valid() bool

Valid reports whether c is a known criticality level. The empty value (unclassified) is not valid here; callers decide how to treat unset criticality.

type ExcludeRule added in v0.42.0

type ExcludeRule struct {
	// Paths matches the finding's location. A pattern ending in "/" matches everything beneath
	// that directory; otherwise it is a glob (path.Match) against the whole location, so
	// "*.md" and "test/fixture.go" both work.
	Paths []string `yaml:"paths,omitempty"`
	// Rules matches the finding's rule id. `*` is a wildcard for any run of characters,
	// including separators — a rule id is an opaque string rather than a path, and the ids that
	// most need matching are compound (`license/GPL-3.0-only/github.com/somelib/thing`), so a
	// wildcard that stopped at `/` could not express "this license, any package". A pattern
	// with no `*` matches exactly. There is no escape for a literal `*`; no scanner emits one.
	Rules []string `yaml:"rules,omitempty"`
	// Reason is why this exclusion exists. Required.
	Reason string `yaml:"reason"`
	// AcceptedBy names who decided this finding was acceptable.
	//
	// The question an auditor asks of a suppression is not whether the scanner ran — it is who
	// decided, and when. `reason` answers why; without this the who lives in prose if it is
	// recorded at all, and a name buried in a sentence cannot be reported on. Optional, and a
	// suppression without one is reported as unattributed rather than rejected.
	AcceptedBy string `yaml:"acceptedBy,omitempty"`
	// Expires is the date this exclusion stops applying, as YYYY-MM-DD.
	//
	// An exclusion accepted "until the upstream fix lands" has nothing that brings the finding
	// back, so the temporary ones become permanent by default — which is how a suppression
	// mechanism decays into a way of never seeing something again. Past this date the exclusion
	// no longer suppresses and the finding returns, with the report saying it lapsed rather than
	// silently producing a finding that used to be accepted.
	Expires string `yaml:"expires,omitempty"`
	// VEX states what this suppression means as a machine-readable claim about the product,
	// for `--report vex`. Optional: without it the finding is reported as `affected`, which is
	// the claim that is never an overstatement.
	VEX *VEXDecision `yaml:"vex,omitempty"`
	// Source is the file this rule was read from, set by the loader rather than by the
	// descriptor. Splitting exclusions across files is only safe if the report can still say
	// which file authorized each one, so the provenance travels with the rule.
	Source string `yaml:"-"`
}

ExcludeRule suppresses findings that match it. Every real repository has paths that are not the application — fixtures, examples, generated code — and rules that do not apply to them.

Two properties are deliberate. A reason is **required**, so the why is in the diff where a reviewer sees it rather than in someone's memory. And a matched finding is *suppressed*, not deleted: it stays in the report marked with its justification, so an exclusion is auditable and cannot become a blind spot nobody can see.

func (ExcludeRule) ExpiredOn added in v0.51.0

func (e ExcludeRule) ExpiredOn(now time.Time) bool

ExpiredOn reports whether this exclusion has lapsed as of the given day.

Compared by date rather than by instant: an exclusion set to expire on the 14th applies throughout the 14th and stops on the 15th, which is what a reader of the descriptor expects from a date with no time on it.

func (ExcludeRule) Matches added in v0.42.0

func (e ExcludeRule) Matches(uri, ruleID string) bool

Matches reports whether a finding at uri with rule id ruleID falls under this exclusion.

When both selectors are set they must both match. That is the narrow reading — "this rule, in this place" — and it is the safe one: the alternative would silently widen "ignore the test fixture's fake key" into "ignore that rule everywhere".

type ExploitabilityConfig added in v0.56.0

type ExploitabilityConfig struct {
	// KEV and EPSS each name a source: a file path, "cache" to read what `draugr feeds update`
	// left without touching the network, or "auto" to fetch when the cache is missing or stale.
	// Empty leaves that signal off; either may be used without the other.
	KEV  string `yaml:"kev,omitempty"`
	EPSS string `yaml:"epss,omitempty"`
	// EPSSThreshold is the EPSS probability (0–1) at or above which a finding's severity is
	// raised one band. Zero means the CLI default.
	//
	// A pointer so "not set" and "deliberately zero" stay distinguishable: zero disables the
	// EPSS bump entirely, which is a thing someone might mean.
	EPSSThreshold *float64 `yaml:"epssThreshold,omitempty"`
	// MaxAge is how old a cached feed may be before "auto" refetches it and a scan warns that
	// it is stale. Empty means the built-in default of 24 hours, which tracks EPSS being
	// republished daily.
	//
	// Configurable because a runner deliberately pinned to a known copy of the data has a
	// legitimate reason to say "do not tell me it is old" — reproducing last quarter's verdict
	// requires last quarter's feed.
	MaxAge string `yaml:"maxAge,omitempty"`
}

ExploitabilityConfig turns on severity enrichment from real-world exploitability signals and says where the data comes from.

In the descriptor rather than only in flags because it is a decision about how findings are ranked, and a team that agrees to use KEV needs somewhere to write that down where it gets reviewed — not a flag every pipeline has to remember to pass.

type Exposure added in v0.5.0

type Exposure string

Exposure is a component's risk-exposure level — how reachable it is to an attacker, and so how likely a weakness in it is to be hit. It is one axis of risk prioritization; higher exposure ranks a component's findings higher. The levels are a fixed ladder: an organization may redefine what each means, but not the count. Exposure may be proposed by a surveyor from topology and confirmed by a human. See docs/concepts.md (prioritization).

const (
	ExposurePublic        Exposure = "public"        // internet-facing, no authentication
	ExposureAuthenticated Exposure = "authenticated" // internet-facing, behind authentication
	ExposureInternal      Exposure = "internal"      // reachable within the environment
	ExposureRestricted    Exposure = "restricted"    // namespace- / network-policy-scoped
)

Exposure levels, from most to least exposed.

func (Exposure) Valid added in v0.5.0

func (e Exposure) Valid() bool

Valid reports whether e is a known exposure level. The empty value (unclassified) is not valid here; callers decide how to treat unset exposure.

type Fetcher added in v0.69.0

type Fetcher interface {
	// Fetch returns a directory holding the repository at the requested revision, the commit it
	// resolved to, and a cleanup.
	Fetch(url, revision string) (dir, resolved string, cleanup func(), err error)
}

Fetcher materializes a remote fragment reference as a local directory.

An interface because fetching means git, which lives in internal/ and cannot be imported from pkg/. The same shape as sbom.Generator: the package declares what it needs, and the wiring supplies something that can do it. A nil Fetcher makes a remote reference an error naming it, rather than a descriptor that quietly contains less than it says.

type Fragment

type Fragment struct {
	// Components are merged by name — a repeated name unions the two surfaces rather than
	// replacing or colliding, so a component described in two places ends up whole.
	Components []Component `yaml:"components,omitempty"`
	// Config is the subset of a Saga's config a fragment may carry.
	Config FragmentConfig `yaml:"config,omitempty"`
	// Fragments are further fragments this one pulls in, resolved relative to it.
	Fragments []FragmentRef `yaml:"fragments,omitempty"`
	// ExposureReasons explains, per component name, what topology a proposed `exposure` was read
	// from — "an Ingress routes into it", and so on.
	//
	// Never serialized: it is evidence about a proposal rather than part of the descriptor, and a
	// fragment somebody writes by hand has no use for it. It exists so a survey can put the
	// reasoning beside the value it wrote, where the value gets reviewed.
	ExposureReasons map[string]string `yaml:"-" json:"-"`
}

Fragment is a partial Saga: what a `fragments:` entry loads, and what a Surveyor contributes.

One type for both because the merge is the same. A surveyor that discovers a cluster and a file a person wrote are both saying "here is some more of the application", and having two merges would mean two answers to what a repeated component name means.

func LoadFragment added in v0.69.0

func LoadFragment(data []byte, path string) (Fragment, error)

LoadFragment parses a Saga fragment, substituting ${{ VAR }} from the environment.

Validated as a fragment rather than as a Saga. A fragment has no `release:` and that is correct, so checking it against the Saga's rules would reject every valid one; and a fragment that is only meaningful once merged is a fragment nobody can check on its own.

func (Fragment) Validate added in v0.69.0

func (f Fragment) Validate() error

Validate checks a fragment on its own terms.

type FragmentConfig added in v0.69.0

type FragmentConfig struct {
	// Exclude suppresses findings that match, with a stated reason. Appended to whatever the
	// descriptor and other fragments already carry.
	Exclude []ExcludeRule `yaml:"exclude,omitempty"`
}

FragmentConfig is the part of Config a fragment is allowed to set.

A separate type rather than a validated Config, so the restriction is enforced by the decoder and shows up in the published schema — an editor says `gate` is not allowed here, rather than the user finding out when a scan behaves unexpectedly.

type FragmentRef added in v0.69.0

type FragmentRef struct {
	// Path selects the fragment files. Globs are the same dialect as `paths:` and `ignore:` —
	// `*` within a segment, `**` across them — so `**/draugr.saga-fragment.yaml` collects one
	// fragment from every component in a monorepo. Relative to the file that names it, so a
	// fragment keeps working when its directory moves.
	Path string `yaml:"path"`
	// URL is a git repository to read the fragments from. Empty means the local filesystem.
	URL string `yaml:"url,omitempty"`
	// Revision is the branch, tag or commit to read. Required when URL is set, and deliberately
	// not defaulted to the repository's default branch: a fragment that tracks a moving branch is
	// a gate that changes with no commit in your own repository. The revision a run resolved to
	// is recorded in the report, so a tag that moves is visible afterwards.
	Revision string `yaml:"revision,omitempty"`
}

FragmentRef names Saga fragments to merge into the descriptor that lists it.

A fragment adds scope or adds attributed suppressions; it can never change policy. That is what makes splitting a descriptor safe to review: including a file cannot quietly lower the gate or switch a control off, so the worst a `fragments:` entry can do is add findings or add suppressions that are individually attributed and counted in the report.

func (FragmentRef) Remote added in v0.69.0

func (f FragmentRef) Remote() bool

Remote reports whether this reference reads from another repository.

func (FragmentRef) String added in v0.69.0

func (f FragmentRef) String() string

String names the reference the way an error should, so a message about one fragment among several says which.

type GateConfig added in v0.43.0

type GateConfig struct {
	// Controls sets a per-control severity threshold, overriding --fail-on for that control
	// only. Values are severity bands: critical, high, medium, low.
	//
	// This exists because one threshold cannot serve every control. License policy is owned by
	// legal and vulnerability policy by security; "fail the build on a forbidden license but
	// only warn on a medium CVE" is a reasonable position that a single global threshold makes
	// unsayable.
	Controls map[string]string `yaml:"controls,omitempty"`

	// FailOnPriority also fails the build on any finding at or above a priority band.
	//
	// Severity rates a flaw in the abstract; priority folds in what the descriptor says about the
	// component it was found in. A team that has classified its components usually wants the gate
	// on the second, and until now could only say so with a flag — which every pipeline has to
	// remember, and which nothing reviews.
	//
	// In the descriptor for the same reason as the rest of this block: it is a decision about
	// this application, reviewed in a pull request and applied identically by every runner. A
	// --fail-on-priority flag still overrides it, so a stricter run stays possible without
	// editing the file.
	FailOnPriority string `yaml:"failOnPriority,omitempty"`
}

GateConfig tunes which findings fail the build.

type Host

type Host struct {
	Name string `yaml:"name"`
	URL  string `yaml:"url"`
	Type string `yaml:"type,omitempty"`
	// Auth authenticates the dynamic scan of this endpoint. Absent means probe it anonymously.
	Auth *HostAuth `yaml:"auth,omitempty"`
	// Spec drives the dynamic scan from an OpenAPI document instead of crawling.
	Spec *HostSpec `yaml:"spec,omitempty"`
}

Host is a running endpoint. Type is "browser" (browser-facing UI) or "api" (programmatic); it tunes which security-header checks apply. Optional; defaults to "browser".

type HostAuth added in v0.93.0

type HostAuth struct {
	// Type is "bearer" — an `Authorization: Bearer <token>` header — or "header" for a named one.
	Type string `yaml:"type"`
	// Header is the header name, required when Type is "header" (e.g. X-API-Key).
	Header string `yaml:"header,omitempty"`
	// TokenEnv names the environment variable holding the credential.
	TokenEnv string `yaml:"tokenEnv"`
}

HostAuth says how to authenticate to an endpoint, by naming the environment variable that holds the credential.

There is deliberately no field for the credential itself. A descriptor is committed, so a token written into one is a leaked token — and `secrets` would rightly flag it. Making the value inexpressible is a stronger guarantee than warning about it.

type HostSpec added in v0.93.0

type HostSpec struct {
	// Path is the OpenAPI or Swagger document, resolved like every other path in a
	// descriptor: relative to where Draugr runs, not to the descriptor or the repository.
	Path string `yaml:"path"`
	// Methods are the HTTP methods to exercise. Empty means GET and HEAD.
	//
	// A specification lists POST, PUT and DELETE too, and a scanner handed one will exercise them
	// — a scan of a staging API that deletes its fixtures is a scan nobody runs twice. Naming a
	// write method here is how that is accepted: explicit, per endpoint, and visible in review.
	Methods []string `yaml:"methods,omitempty"`
}

HostSpec points the dynamic scan at an OpenAPI document describing this endpoint.

An API usually has no HTML to crawl, so probing it blind reaches whatever a scanner can guess. A specification lists every route and method the service declares, which turns guessing into exercising what is actually there.

type Image

type Image struct {
	Image  string `yaml:"image"`
	Digest string `yaml:"digest,omitempty"`
	// BuiltBy says who builds this image: "self" (the default) or "upstream" for one this
	// component runs but somebody else publishes.
	//
	// It decides what the report tells a reader to do about a vulnerable package inside it.
	// Nobody running a scan can upgrade a library inside an image they do not build — the fix is
	// a newer image, or a wait for whoever publishes it. Advice they cannot take, at the top of a
	// list called "fix first", teaches them the list is not worth reading.
	//
	// Declared rather than detected, because nothing in an image says who built it. Defaults to
	// "self" so a descriptor that says nothing keeps describing its own work, which is the common
	// case for a hand-written one — a surveyed cluster is the case that needs saying.
	BuiltBy BuiltBy `yaml:"builtBy,omitempty"`
}

Image is a container image reference. Digest is the immutable content digest ("sha256:…") of the image the tag pointed to; when present it makes result caching content-addressed (a rebuilt image under the same tag re-scans). A surveyor can capture the running digest, or you can pin it by hand for reproducible caches.

type Infrastructure

type Infrastructure struct {
	Kind string `yaml:"kind"`
	Ref  string `yaml:"ref,omitempty"`
	// Namespaces narrows the audit to the namespaces this component owns. Empty means the whole
	// cluster.
	//
	// On a shared cluster the cluster is not the unit anyone owns. Most of what the benchmark's
	// policies section examines is namespace-scoped, so a team owning three namespaces of eighty
	// otherwise receives seventy-seven namespaces' worth of findings it cannot act on — and a
	// number that will never reach zero is a number people stop reading.
	//
	// It also fixes what the component's risk classification means. `exposure` and `criticality`
	// describe a component, so declaring them against a whole shared cluster asserts them on
	// everybody else's workloads too.
	Namespaces []string `yaml:"namespaces,omitempty"`
	// OperatedBy says who runs this surface: "self", or "provider" for a managed service.
	//
	// It states a fact rather than a judgement, and what follows from it — that a finding about
	// the provider's half is not something this team can go and fix — is derived rather than
	// asserted. "managed" was the obvious word and is ambiguous: managed by whom, and a managed
	// service is still yours to pay for.
	//
	// Declared rather than detected, because whether a cluster is managed is a fact about a
	// contract and not something visible in what a scanner reads. The same argument that puts
	// exposure and criticality here.
	//
	// It narrows what it excuses. On a managed cluster the provider runs the control plane, the
	// API server and etcd; RBAC, Pod Security and network policy remain the team's, and those
	// are usually the findings that matter. Marking a whole cluster as somebody else's problem
	// would hide the half that is not.
	OperatedBy OperatedBy `yaml:"operatedBy,omitempty"`
}

Infrastructure is an infrastructure surface. Kind is e.g. "kubernetes"; Ref names the concrete instance.

type Model

type Model struct {
	Release    Release       `yaml:"release"`
	Config     Config        `yaml:"config,omitempty"`
	Components []Component   `yaml:"components,omitempty"`
	Fragments  []FragmentRef `yaml:"fragments,omitempty"`
	References []Reference   `yaml:"references,omitempty"`
}

Model is a parsed Saga descriptor — the declarative account of an application's security surface plus the controller configuration that drives a scan.

func Load

func Load(data []byte) (*Model, error)

Load parses a Saga descriptor from YAML bytes, substituting ${{ VAR }} references from the environment and validating the result.

func LoadFile

func LoadFile(path string) (*Model, error)

LoadFile reads and parses a Saga descriptor, merging any local fragments it names.

Remote fragments need a Fetcher, which needs git, which lives in internal/ — so a descriptor using one gets an error here naming it rather than a descriptor that quietly contains less than it says. Callers that can fetch use ResolveFile.

func (*Model) Validate

func (m *Model) Validate() error

Validate checks the descriptor for structural correctness, returning all problems at once (joined) rather than only the first.

type OperatedBy added in v0.96.0

type OperatedBy string

OperatedBy says who runs an infrastructure surface.

const (
	// OperatedBySelf is the default: this team runs it, so every finding is theirs to act on.
	OperatedBySelf OperatedBy = "self"
	// OperatedByProvider is a managed service, where part of the surface is not reachable by
	// the team that owns the workloads on it.
	OperatedByProvider OperatedBy = "provider"
)

Who operates a surface.

func (OperatedBy) Valid added in v0.96.0

func (o OperatedBy) Valid() bool

Valid reports whether the value is one Draugr defines.

type PublisherConfig added in v0.21.0

type PublisherConfig struct {
	Kind string `yaml:"kind"`
	Dir  string `yaml:"dir,omitempty"` // file: output directory

	// github / github-pr-comment: Repo defaults to $GITHUB_REPOSITORY; the token to $GITHUB_TOKEN
	// (or TokenEnv). github: Commit/Ref default to $GITHUB_SHA / $GITHUB_REF.
	Repo     string `yaml:"repo,omitempty"`
	Commit   string `yaml:"commit,omitempty"`
	Ref      string `yaml:"ref,omitempty"`
	TokenEnv string `yaml:"tokenEnv,omitempty"` // env var holding the token; default GITHUB_TOKEN

	// github-pr-comment / azure-pr-comment: posts the markdown report as a sticky pull-request
	// comment. PR defaults to the number parsed from $GITHUB_REF (refs/pull/<n>/merge) or
	// $SYSTEM_PULLREQUEST_PULLREQUESTID; Marker identifies the sticky comment to update
	// (default a Draugr marker).
	PR     int    `yaml:"pr,omitempty"`
	Marker string `yaml:"marker,omitempty"`

	// azure-pr-comment: Org is the collection URI (default $SYSTEM_TEAMFOUNDATIONCOLLECTIONURI)
	// and Project the team project (default $SYSTEM_TEAMPROJECT). Repo defaults to
	// $BUILD_REPOSITORY_NAME and the token to $SYSTEM_ACCESSTOKEN (or TokenEnv).
	Org     string `yaml:"org,omitempty"`
	Project string `yaml:"project,omitempty"`
}

PublisherConfig configures one destination for rendered reports. Kind selects the publisher (e.g. "file", "github"); the remaining fields are read by that publisher. Known kinds and their required fields are validated by the publishing layer (pkg/publish) when the scan runs.

Secrets are never stored here: the github publisher reads its token from an environment variable (TokenEnv, default GITHUB_TOKEN), not from the Saga.

type Reference

type Reference struct {
	Type string `yaml:"type"`
	Link string `yaml:"link"`
}

Reference links a manual/human security control (e.g. threat model, architecture diagram).

type Release

type Release struct {
	Name    string `yaml:"name"`
	Version string `yaml:"version"`
	Stage   string `yaml:"stage,omitempty"`
}

Release identifies what is being assessed.

type ReportConfig added in v0.21.0

type ReportConfig struct {
	Format string `yaml:"format"`
	// Template and TemplateFile supply the Go text/template for the "template" format (set
	// exactly one). Ignored by other formats.
	Template     string `yaml:"template,omitempty"`
	TemplateFile string `yaml:"templateFile,omitempty"`
	// Filename overrides the artifact's default output filename (used by file-based publishers).
	Filename string `yaml:"filename,omitempty"`
	// MinPriority narrows this report to findings at or above a priority band, leaving every
	// other report complete.
	//
	// Per report, because the reports answer to different readers. A SARIF upload becomes review
	// comments somebody reads on every pull request, where a few hundred findings they did not
	// cause is the reason nobody reads any of them; the JSON beside it is evidence, and evidence
	// is not something to trim.
	//
	// Narrowing an artifact is otherwise refused, and for a good reason: a file that claims to be
	// the scan and is not misleads whatever consumes it. What makes this different is that it is
	// declared — written in the descriptor, and stated inside the artifact it produced. A scope
	// somebody chose and can read back is a scope; an undeclared one is the problem.
	MinPriority string `yaml:"minPriority,omitempty"`
}

ReportConfig selects one report format to render on a scan. Known formats are validated by the reporting layer (pkg/report) when the scan runs, not here — the Saga stays a leaf.

type Repository

type Repository struct {
	URL      string `yaml:"url"`
	Revision string `yaml:"revision,omitempty"`
	// Paths restricts the scan to these directories. Empty scans the whole repository.
	//
	// Files at the repository root are always included regardless: manifests and the scanners'
	// own configuration live there, and a tool that cannot see go.mod or .trivyignore does not
	// fail — it reports less against a tree it did not fully understand, which is
	// indistinguishable from a clean scan.
	Paths []string `yaml:"paths,omitempty"`
	// Ignore removes matching paths from the scan, applied after Paths so it can carve out of
	// one. Gitignore-style: a trailing `/` is a directory, `*` matches within a path segment,
	// `**` across them.
	Ignore []string `yaml:"ignore,omitempty"`
}

Repository is a source repository at a revision, optionally scoped to part of its tree.

type Resolved added in v0.69.0

type Resolved struct {
	Model   *Model
	Sources []Source
}

Resolved is a descriptor with every fragment merged in, plus where each part came from.

func ResolveFile added in v0.69.0

func ResolveFile(path string, fetcher Fetcher) (*Resolved, error)

ResolveFile loads a descriptor and merges every fragment it names.

The root is the merge base and fragments are applied after it, which is the opposite of the usual "most specific wins" layering and is deliberate. Components merge by union keeping the first value for scalars, so making the root the base is what keeps the file someone opened authoritative about a component's classification. Fragments are applied later and win less, which is right because they are additive.

type SBOMConfig added in v0.41.0

type SBOMConfig struct {
	// Enabled generates one SBOM per distinct repository and image in the project.
	Enabled bool `yaml:"enabled"`
	// Format is the document format. Empty means SBOMCycloneDXJSON.
	Format SBOMFormat `yaml:"format,omitempty"`
	// Scope is what each document covers: one target, the whole project, or both. Empty means
	// SBOMScopeComponent, which is the behavior a descriptor written before this field had.
	Scope SBOMScope `yaml:"scope,omitempty"`
}

SBOMConfig turns on SBOM generation and chooses the document format.

type SBOMFormat added in v0.41.0

type SBOMFormat string

SBOMFormat is the document format for a generated SBOM. Both supported formats are open specifications that downstream tooling already reads.

const (
	// SBOMCycloneDXJSON is the default: the OWASP format in JSON, ECMA-424, and the one that
	// composes — a CycloneDX document can carry nested components and describe how complete it
	// is, which is what a document covering a whole project needs. It is also the format
	// security tooling reads most readily, and the one VEX is expressed in.
	SBOMCycloneDXJSON SBOMFormat = "cyclonedx-json"
	// SBOMCycloneDXXML is CycloneDX in XML, which some enterprise tooling still expects.
	SBOMCycloneDXXML SBOMFormat = "cyclonedx-xml"
	// SBOMSPDXJSON is SPDX in JSON, ISO/IEC 5962, and what a procurement or license-compliance
	// process is most likely to ask for by name.
	SBOMSPDXJSON SBOMFormat = "spdx-json"
	// SBOMSPDXTagValue is SPDX in its original tag-value encoding, still required by some
	// compliance tooling.
	SBOMSPDXTagValue SBOMFormat = "spdx-tag-value"
)

The SBOM document formats Draugr can emit: the two open specifications, each in both of its standard encodings. Which one you want is decided by whatever consumes the document, so the choice is yours rather than ours.

Syft can emit more (its own syft-json, GitHub's dependency-snapshot format, a bare PURL list), but those are either vendor-specific or not an SBOM. Keeping this list to the interchange formats means every document Draugr produces is one a third party can read.

func (SBOMFormat) Valid added in v0.41.0

func (f SBOMFormat) Valid() bool

Valid reports whether f is a known SBOM format. The empty value is not valid here; it means "the default" to callers, which resolve it before use.

type SBOMScope added in v0.68.0

type SBOMScope string

SBOMScope is what a generated SBOM document covers.

The distinction exists because an SBOM is requested per *product* — a customer questionnaire, EO 14028 and the CRA all ask for the bill of materials of the thing you shipped — while Draugr scans per repository and image. A project with four repositories and three images produces seven documents and no answer to the question being asked.

const (
	// SBOMScopeComponent is the default: one document per distinct repository and image.
	SBOMScopeComponent SBOMScope = "component"
	// SBOMScopeProject is one document covering the whole release, assembled along the hierarchy
	// the Saga already declares.
	SBOMScopeProject SBOMScope = "project"
	// SBOMScopeBoth emits the per-target documents and the assembled one. The parts are the
	// evidence for the whole, and an auditor asking where a package came from wants both.
	SBOMScopeBoth SBOMScope = "both"
)

The scopes a Saga may ask for.

func (SBOMScope) PerTarget added in v0.68.0

func (s SBOMScope) PerTarget() bool

PerTarget reports whether this scope asks for the per-repository and per-image documents.

func (SBOMScope) Project added in v0.68.0

func (s SBOMScope) Project() bool

Project reports whether this scope asks for an assembled project document.

func (SBOMScope) Valid added in v0.68.0

func (s SBOMScope) Valid() bool

Valid reports whether s is a known scope. The empty value is not valid here; it means "the default" to callers, which resolve it before use.

type Source added in v0.69.0

type Source struct {
	// Path is the file, as written in the descriptor that named it (or the root's own path).
	Path string
	// URL is the repository it came from, empty for a local file.
	URL string
	// Revision is what the descriptor asked for, and Resolved is the commit that turned out to
	// be. Both empty for a local file.
	Revision string
	Resolved string
	// Root marks the descriptor the resolution started from.
	Root bool
}

Source is one file that contributed to a resolved descriptor.

Kept beside the merged Model rather than folded into it, so a report can say where each part came from. Splitting a descriptor is only safe if the result is still answerable — a suppression nobody can trace to a file is worse than one in a long file.

func (Source) String added in v0.69.0

func (s Source) String() string

String renders a source the way a report or an error should name it.

type VEXConfig added in v0.68.0

type VEXConfig struct {
	// Author identifies who is making these statements: an organization, ideally with a way to
	// reach them ("Example Ltd <security@example.com>"). Defaults to the release name, which is
	// a project rather than a party — enough to produce a valid document, not enough for one
	// you would hand to a customer.
	Author string `yaml:"author,omitempty"`
	// Product identifies the thing the statements are about, as an IRI — a package URL is the
	// conventional choice ("pkg:oci/example/api@sha256:…").
	//
	// Defaults to a purl built from the release name and version. Set it to whatever the SBOM
	// a consumer holds calls this product: a VEX statement is matched to a component by
	// identifier, so a document naming the product differently is one nothing will apply.
	Product string `yaml:"product,omitempty"`
}

VEXConfig names the party making the claims in a generated VEX document, and the product they are about.

Both are optional and both are worth setting for a document you publish. A VEX document is an assertion by a supplier about their own product, so the two things a consumer needs are *who says so* and *what about*. Draugr can guess the second from the release and cannot guess the first at all — it knows a project name, not a legal entity or a contact.

type VEXDecision added in v0.68.0

type VEXDecision struct {
	// Status is the claim: not_affected, affected, or fixed.
	//
	// under_investigation is deliberately not accepted here. It is what an untriaged finding
	// already reports, and claiming it on a finding you have suppressed says two contradictory
	// things — that the matter is open, and that it is settled.
	Status string `yaml:"status"`
	// Justification is why the product is not affected, from VEX's fixed vocabulary. Valid only
	// with not_affected, where VEX requires either this or a prose statement; without it the
	// exclusion's `reason` is emitted as the prose form instead.
	//
	// A closed vocabulary because the whole value of the field is that a consumer can act on it
	// without reading English.
	Justification string `yaml:"justification,omitempty"`
}

VEXDecision states what a suppression means in VEX terms. Optional, and absent is a perfectly good answer — see the status table below for what Draugr emits without it.

This is separate from `reason` because they answer different questions. A reason is written for a human reviewing the descriptor; a VEX status is a machine-readable claim about the product, which downstream consumers act on. Deriving one from the other would mean inferring a compliance statement from free text, and detection, severity and evidence in Draugr do not involve inference.

Jump to

Keyboard shortcuts

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