sdk

package
v0.3.0 Latest Latest
Warning

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

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

Documentation

Overview

Package sdk is the public, importable surface for writing an OSCTF plugin. An author implements one small Go interface per plugin type and calls Serve; the go-plugin handshake, the gRPC transport, and the generated wire types (pluginpb) are all hidden. This is the ONLY OSCTF package a plugin imports — never internal/*.

The wire format is deliberately WRAPPED, not aliased. The whole point of versioning the ABI is that the wire can change under a stable author-facing surface: an alias would make the protobuf the author's API, so any wire change would break every plugin and the ABI major version would buy nothing. The translation (the line count) is what makes that real.

The SDK owns the contract facts — the plugin TYPE (from the Serve argument), the ABI (a fact of the SDK build), and the CAPABILITIES (derived structurally from the interfaces an impl satisfies). An author declares none of them, so none can be misdeclared.

See docs/v0.3/02-plugin-abi.md and docs/v0.3/11-plugin-template.md.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func EventKeys

func EventKeys(event string) []string

EventKeys returns the Data keys the given event type carries — the fields you can read from an sdk.Event.Data in a Notifier. For example EventKeys("challenge.solved") returns challenge_id, challenge_slug, team_id, user_id. Empty for an unknown event.

These are the SAME keys core emits (one shared definition, pinned by a test), so they cannot drift from what your Notifier actually receives. All values are non-secret, non-PII.

func Serve

func Serve(t PluginType, impl any)

Serve runs the plugin over the OSCTF handshake and BLOCKS until the host disconnects (or the process is killed). It wires go-plugin + gRPC; the author touches neither. t must match impl — a Scoring plugin's impl must be a Scorer, an Auth plugin's a PasswordAuth and/or RedirectAuth, and so on — and a mismatch panics before serving, so it surfaces on the first run rather than as a silent no-op.

Types

type BeginRedirect

type BeginRedirect struct {
	// AuthorizeURL is where the browser is sent. Its `state` query parameter MUST be the state
	// the host passed to Begin, verbatim — the host verifies this and refuses the login otherwise.
	AuthorizeURL string
	// State is YOUR opaque round-trip data (a PKCE verifier, a nonce, whatever Complete needs).
	// The host stores it and hands it back to Complete. It is NOT the CSRF state, it never
	// reaches the browser, and it never goes to the identity provider.
	State string
}

BeginRedirect is what a redirect-capability auth plugin returns to start an external login.

type Checker

type Checker interface {
	Info() Info
	// ValidateConfig runs at author time (when a challenge is created/edited), not on the submit
	// path — reject bad config before an event, not during one. Return OK=false with per-field
	// messages to reject the save (the admin sees a 422 with those fields); return Normalized to
	// canonicalise what the host stores. The config is per-challenge author input and MAY CONTAIN
	// CHALLENGE-SENSITIVE DATA (see FlagCheck.Config) — never log it.
	ValidateConfig(config map[string]string) ConfigValidation
	// CheckFlag decides whether a submission is correct. Returning an error means "could not
	// decide" (the host fails the check closed — the attempt is not consumed), which is
	// different from returning (false, nil), a decided-incorrect.
	CheckFlag(FlagCheck) (correct bool, err error)
}

Checker is implemented by a challenge-type plugin.

type ConfigValidation

type ConfigValidation struct {
	OK          bool
	FieldErrors map[string]string
	Normalized  map[string]string
}

ConfigValidation is the result of author-time config validation. OK reports whether the config is usable; FieldErrors maps a config key to a human message when not; Normalized is the config the host should store in place of the raw input (defaults filled, values canonicalised).

type Event

type Event struct {
	Name       string            // e.g. "challenge.solved"
	ID         string            // unique event id (for dedupe if the author needs it)
	OccurredAt string            // RFC3339 timestamp, as a string on the wire
	Data       map[string]string // event-specific fields — the documented keys are EventKeys(Name)
}

Event is one thing that happened, delivered to a subscribed notification plugin. Data carries the event-specific fields (e.g. team, challenge for "challenge.solved"). Delivery is best-effort and fire-and-forget: the host does not block a solve on a notification, and a full queue drops the newest event (counted, never silent) — so a Notifier must not assume it sees every event, and must not do slow work inline.

type FlagCheck

type FlagCheck struct {
	Submitted string
	Config    map[string]string
	Instance  map[string]string
}

FlagCheck is the input to a flag check. Submitted is what the player sent; Config is the challenge's per-challenge type_config (author-defined, validated + normalized at author time by ValidateConfig). The real static flag is NEVER sent to a plugin — a challenge-type plugin decides correctness from Config (and, once wired, Instance), not by being handed the answer.

Instance is RESERVED and ALWAYS EMPTY today — do not read it. It is meant to carry per-team instance context (e.g. a per-instance secret) for a containerised per_instance challenge, but the host currently passes an empty map: a challenge that is BOTH per_instance and plugin-typed is not a wired combination yet (the built-in per_instance path handles per-team flags today). It will be populated from the instances table when that combination is supported, so treat it as absent, not merely unset — the same reserved-until-wired shape as sdk.Score.Params.

Config MAY CONTAIN CHALLENGE-SENSITIVE DATA — a regex or rule that reveals the flag's structure is the obvious case. NEVER log it, or any value derived from it: sdk.Log output reaches the host log, and a flag hint leaked there defeats the challenge.

type Identity

type Identity struct {
	Subject  string            // stable unique id at the provider (the "sub")
	Email    string            // may be empty
	Username string            // suggested username; the host decides the actual one
	Claims   map[string]string // informational only — see the type doc; NOT authority

	// EmailVerified reports whether YOU verified this address (OIDC's email_verified claim).
	// Set it truthfully: the host requires it before binding a login to an EXISTING account by
	// email, so leaving it false is the safe default and simply means the host will not match on
	// email. Do not set it because the address merely looks plausible — an address you did not
	// verify lets a user assert someone else's account. Added in ABI 1.1.
	EmailVerified bool
}

Identity is what an auth plugin returns for an authenticated principal.

IMPORTANT — an Identity is a CLAIM, not a grant, and this SDK surface is deliberately ahead of the host. As of this build NO auth plugin can be loaded: the plugin registrar's auth arm is nil until milestone M3, and M3 ships RETURN-PATH VALIDATION first (docs/v0.3/10-milestones.md). When it lands, the host maps an Identity to a local user under HOST policy — a plugin cannot set a role, mint an admin, or self-provision. Claims in particular is informational: putting "role":"admin" (or anything like it) in Claims grants nothing. Do not build a plugin that assumes an Identity confers authority; the defense that constrains it is the host's, by design, and it is not the plugin's to bypass. Until M3 you can develop and contract-test an auth plugin, but the host will not call it.

type Info

type Info struct {
	Name    string
	Version string
}

Info is what a plugin advertises about itself: Name is the manifest name the host registers the provider under; Version is the plugin's own release. Type, ABI, and Capabilities are NOT here — the SDK owns them (see the package doc), so a plugin cannot misdeclare the contract it speaks.

type Logger

type Logger struct {
	// contains filtered or unexported fields
}

Logger is the plugin's logger. Its output travels over go-plugin's stderr channel into the HOST's logs, tagged as plugin-origin so an operator can tell it from the platform's own lines. The surface is deliberately minimal — debug/info/warn/error, a message plus key/value pairs — not a logging framework.

DO NOT LOG SECRETS OR PAYLOADS. Everything you log lands in the operator's host logs: your config values, an event's data, a submitted flag, a token — none of it belongs in a log line. The SDK cannot stop you; this is the rule. The host also rate-limits and truncates plugin log output, so a chatty or crash-looping plugin cannot flood or degrade the host through a channel that exists for the plugin's benefit — do not rely on every line being kept.

func Log

func Log() *Logger

Log returns the plugin logger (created once).

func (*Logger) Debug

func (l *Logger) Debug(msg string, kv ...any)

Debug/Info/Warn/Error log at the named level. Args are alternating key, value pairs.

func (*Logger) Error

func (l *Logger) Error(msg string, kv ...any)

func (*Logger) Info

func (l *Logger) Info(msg string, kv ...any)

func (*Logger) Warn

func (l *Logger) Warn(msg string, kv ...any)

type Notifier

type Notifier interface {
	Info() Info
	// Subscriptions returns the event names to receive; a single "*" means all events.
	Subscriptions() []string
	// Notify handles one delivered event. Returning an error signals the host the delivery
	// failed (it is logged/counted); returning nil means handled. It must be quick — do slow
	// or unreliable work (HTTP posts, etc.) on the plugin's own goroutine/queue, not inline.
	Notify(Event) error
}

Notifier is implemented by a notification plugin.

type PasswordAuth

type PasswordAuth interface {
	Info() Info
	// Authenticate verifies the credential. A nil error with an Identity means success; an error
	// means the credential was rejected or could not be checked (the host treats both as a failed
	// login — it never falls open). See the Identity doc: success is a claim, not a grant.
	Authenticate(identifier, secret string) (Identity, error)
}

PasswordAuth is the "password" capability: verify a submitted identifier/secret directly. An auth plugin implements this, RedirectAuth, or both — Serve derives the advertised capabilities from which it satisfies.

type PluginConfig

type PluginConfig struct {
	// contains filtered or unexported fields
}

PluginConfig is the plugin's resolved configuration: the manifest's declared keys plus any OSCTF_PLUGIN_<NAME>_<KEY> environment overrides (env wins; secrets come from env only). The host validates it against the manifest schema BEFORE the plugin launches — a required key is present and typed values parse — so a plugin that reads its config can trust it.

func Config

func Config() *PluginConfig

Config returns the plugin's configuration, parsed once from the host-provided environment. Use it inside your Serve impl, e.g. sdk.Config().String("webhook_url").

func (*PluginConfig) Bool

func (c *PluginConfig) Bool(key string) bool

Bool returns key parsed as a bool, or false if unset.

func (*PluginConfig) Has

func (c *PluginConfig) Has(key string) bool

Has reports whether key was provided.

func (*PluginConfig) Int

func (c *PluginConfig) Int(key string) int

Int returns key parsed as an int, or 0 if unset. The host validated the declared type, so a declared int key parses.

func (*PluginConfig) String

func (c *PluginConfig) String(key string) string

String returns the value for key, or "" if it was not provided.

func (*PluginConfig) StringOr

func (c *PluginConfig) StringOr(key, def string) string

StringOr returns the value for key, or def if it was not provided.

type PluginType

type PluginType string

PluginType is the kind of provider a plugin implements. It matches the manifest `type`.

const (
	Scoring       PluginType = "scoring"
	Notification  PluginType = "notification"
	ChallengeType PluginType = "challenge_type"
	Auth          PluginType = "auth"
)

The four plugin types.

type RedirectAuth

type RedirectAuth interface {
	Info() Info
	// Begin starts an external login. `state` is minted by the HOST and must be used verbatim as
	// the authorize URL's `state` parameter; do not generate your own. `redirectURI` is the host
	// callback to register with the provider.
	Begin(state, redirectURI string) (BeginRedirect, error)
	// Complete finishes the login. `state` is the opaque value YOU returned from Begin
	// (BeginRedirect.State), not the CSRF state; `params` are the callback query parameters.
	Complete(state string, params map[string]string) (Identity, error)
}

RedirectAuth is the "redirect" capability: an external (e.g. OIDC) login. Begin starts it, Complete finishes it after the provider redirects back.

type Score

type Score struct {
	Initial int // configured initial points
	Min     int // floor the value never drops below
	Decay   int // per-solve decay step (from challenge config)
	Solves  int // valid solves so far, INCLUDING this solve — so the first solver sees Solves == 1
	// Params is RESERVED and ALWAYS EMPTY in v0.3 — do not read it. There is no per-challenge
	// scoring-params source today: the host has no column to hold author-defined scoring params, and
	// the scoring service has no validation RPC to check them at challenge-write time, so nothing is
	// ever put here. Populating it is a v0.4 candidate — it needs a schema change plus a write-time
	// validation path, not a workaround — so treat this field as absent, not merely unset.
	//
	// What you DO have: a scoring plugin is configured PER-DEPLOYMENT via sdk.Config — one set of
	// values for the whole plugin, from the manifest and host env — NOT per-challenge. Its only
	// per-challenge inputs are Initial/Min/Decay/Solves above. Tune with those four, a fixed rule,
	// or sdk.Config. Never Params.
	Params map[string]string
}

Score is the input to a scoring computation — the plain-Go mirror of the wire request.

type Scorer

type Scorer interface {
	Info() Info
	Value(Score) int
}

Scorer is implemented by a scoring plugin.

LOCKED AT SOLVE — the one thing to know before writing Value. Value is called EXACTLY ONCE per solve, at that solve, and the result is RECORDED on the solve. It is not re-evaluated: when a later team solves, earlier solvers keep the value they were given. So a decay curve does not retroactively lower an early solver — it sets what each solver locks in at the moment they solve (Solves is the count at that instant). Value is off the read path (the board reads the record, never the plugin), which is why it must be PURE: same Score in, same value out, no I/O.

NO TIME INPUT — deliberate, not an oversight. Score carries solve ORDER (Solves), never a clock. Value must be a pure function of (Initial, Min, Decay, Solves) so the recorded value stays reproducible from the solve log alone. A time-dependent curve — time-based decay, solve-rate, or "first blood within N minutes" — would make the recorded value un-recomputable from the record, so it is excluded by design. Time-aware scoring is not a missing argument you can add later; it requires extending the recorded-value model, and is out of scope until that exists.

Directories

Path Synopsis
Package contract lets a plugin author verify a built plugin satisfies the OSCTF contract WITHOUT the monorepo — it dials the plugin exactly as the host does, wrapped so no wire type is exposed.
Package contract lets a plugin author verify a built plugin satisfies the OSCTF contract WITHOUT the monorepo — it dials the plugin exactly as the host does, wrapped so no wire type is exposed.

Jump to

Keyboard shortcuts

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