Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion free/ci/cmd/artifacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func openArtifacts(fs *flag.FlagSet) (*artifactsEnv, error) {
}
}
role := authz.Role(firstOf(os.Getenv("NSELF_CI_ROLE"), string(authz.RoleAdmin)))
return &artifactsEnv{st: st, blobs: blobs, reader: records.Reader{Ledger: st, Blobs: blobs}, viewer: authz.Viewer{Project: project, Role: role},
return &artifactsEnv{st: st, blobs: blobs, reader: records.Reader{Ledger: st, Blobs: blobs, Audit: st.AppendAudit}, viewer: authz.Viewer{Project: project, Role: role},
json: fs.Lookup("json").Value.String() == "true"}, nil
}

Expand Down
2 changes: 1 addition & 1 deletion free/ci/cmd/artifacts_release.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func publishDeps(st *store.Store, home, root string) (publish.Deps, error) {
}
env := []string{"NSELF_CI_REPO=" + root, "NSELF_CI_HOME=" + home}
return publish.Deps{Src: st, Blobs: blobs, Ledger: st, Home: home, Root: root, Capture: report.Capture, Team: teamCoordinator(),
Signer: &signing.Client{Runner: &signing.FunnelRunner{Home: home}, Src: st, Env: env, Team: teamCoordinator()}}, nil
Signer: &signing.Client{Runner: &signing.FunnelRunner{Home: home}, Src: st, Env: env, Team: teamCoordinator(), Audit: st.AppendAudit}}, nil
}

// releasePublisher is the run package's publication seam.
Expand Down
12 changes: 8 additions & 4 deletions free/ci/cmd/artifacts_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ package main
// Outputs: a summary or one v1 envelope (--json) whose data always carries
// `slsa_build_level: "L1"`; exit 0 verified, 11 invalid (the reason key names
// the check), 1 evidence failing or quarantined.
// Constraints: V15 only; side-effect class read: it writes nothing (resolving
// the pin fetches the default branch into the repository's remote-tracking ref,
// as the policy resolver does for every run). The trust
// Constraints: V15 only; side-effect class read: it writes nothing but, on a
// machine with a coordinator store, one release.verify.refused audit row per
// refusal (resolving the pin fetches the default branch into the repository's
// remote-tracking ref, as the policy resolver does for every run). The trust
// roots are the operator files and the protected default branch at the pinned
// tip; a pin that cannot be resolved fails the verification (signature), it
// never silently drops the branch's keys and revocations; the working tree and
Expand Down Expand Up @@ -46,6 +47,9 @@ var (
releaseDirOf func(ctx context.Context, run string) (dir string, ok bool, err error)
// releaseQuarantined reports evidence quarantine (P7-TRUST-20 registers it).
releaseQuarantined func(ctx context.Context, ev model.Evidence) (bool, error)
// releaseVerifyAudit records a refused verification in the coordinator store (registered with
// releaseQuarantined); nil records nothing.
releaseVerifyAudit func(ctx context.Context, actor, action, target, detail string) error
)

func init() { registerArtifactsSub("verify", artifactsVerify) }
Expand All @@ -66,7 +70,7 @@ func artifactsVerify(args []string) int {
asJSON := fs.Lookup("json").Value.String() == "true"
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
opt := verify.Options{Capture: report.Capture, Quarantined: releaseQuarantined}
opt := verify.Options{Capture: report.Capture, Quarantined: releaseQuarantined, Audit: releaseVerifyAudit}
root, err := evidenceRoot(*repo)
if err != nil {
return artifactsExit(err, command, asJSON)
Expand Down
21 changes: 20 additions & 1 deletion free/ci/cmd/serve_admin_checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package main
// Purpose: `nself ci serve admin audit checkpoint export <path>` and `... verify --pubkey <fingerprint>
// <path>` (P7-TRUST-20, Epic D13/D20): the TEAM coordinator signs the head of the audit chain with its own
// ci-audit key; verify checks a checkpoint file against a fingerprint pinned off-host and against the live
// chain. It also registers the release quarantine seam of `ci artifacts verify`.
// chain. It also registers the release quarantine and refusal-audit seams of `ci artifacts verify`.
// Inputs: a path; --pubkey (the fingerprint `export` printed); --json; $NSELF_CI_HOME (state.db and the
// 0600 key audit-checkpoint.key, created at the first export); $NSELF_CI_COORDINATOR_MODE=team.
// Outputs: export writes the checkpoint file and prints the public-key fingerprint; verify exits 0 when
Expand Down Expand Up @@ -58,6 +58,25 @@ func init() {
Handler: func(args []string) int { return runAdminCheckpoint(args, productionAdminEnv()) },
})
releaseQuarantined = storeQuarantined
releaseVerifyAudit = storeVerifyAudit
}

// storeVerifyAudit appends a refused release verification to the coordinator store's audit log; a machine
// without a store (a release checked away from its coordinator) records nothing.
func storeVerifyAudit(ctx context.Context, actor, action, target, detail string) error {
path := filepath.Join(lifecycle.CIHome(), "state.db")
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
st, err := store.Open(path, store.Options{})
if err != nil {
return err
}
defer func() { _ = st.Close() }()
return st.AppendAudit(ctx, actor, action, target, detail)
}

// storeQuarantined reads the quarantine index of the coordinator store; a machine without one has nothing
Expand Down
33 changes: 30 additions & 3 deletions free/ci/internal/artifacts/records/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ package records
// entry passes authz.ArtifactPath through an Extractor; a declaration that
// selects nothing, a file over the cap, a registered secret, a low disk and a
// duplicate record name each fail the whole collection (E712 or E683) before
// the attempt can pass; blobs are written only after the scan of their bytes.
// the attempt can pass; blobs are written only after the scan of their bytes;
// with Audit set, each refusal leaves an artifact.collect.refused row.

import (
"context"
"errors"
"fmt"
"io"
"math"
"os"
Expand Down Expand Up @@ -55,6 +58,25 @@ type Collector struct {
Reserve func(total int64) error
// MaxRecords bounds the files one attempt may collect (0 = DefaultMaxRecords).
MaxRecords int
// Audit, when set, records every refused collection (artifact.collect.refused
// with its reason key; TM-14, TM-16). Target names the attempt or lease.
Audit AuditSink
Target string
}

// AuditSink writes one audit row (store.AppendAudit).
type AuditSink func(ctx context.Context, actor, action, target, detail string) error

// audited records a refusal of the collection and returns it; a lost row is joined to it.
func (c Collector) audited(ctx context.Context, err error) error {
var e *Error
if err == nil || c.Audit == nil || !errors.As(err, &e) {
return err
}
if aerr := c.Audit(ctx, "artifacts", "artifact.collect.refused", c.Target, "reason="+e.Reason); aerr != nil {
return errors.Join(err, fmt.Errorf("artifact collect audit: %w", aerr))
}
return err
}

// DefaultMaxRecords is the most files one attempt may collect across all its declarations.
Expand All @@ -71,11 +93,11 @@ type planned struct {
// (opened here; callers that hold a pinned root use CollectRoot).
func (c Collector) Collect(ctx context.Context, project, base string, decls []Decl) ([]Staged, error) {
if info, err := os.Lstat(base); err != nil || !info.IsDir() {
return nil, failf(ReasonUnreadable, "job workdir %q is missing, not a directory or a symlink", base)
return nil, c.audited(ctx, failf(ReasonUnreadable, "job workdir %q is missing, not a directory or a symlink", base))
}
root, err := os.OpenRoot(base)
if err != nil {
return nil, failf(ReasonUnreadable, "job workdir: %v", err)
return nil, c.audited(ctx, failf(ReasonUnreadable, "job workdir: %v", err))
}
defer func() { _ = root.Close() }()
return c.CollectRoot(ctx, project, root, decls)
Expand All @@ -87,6 +109,11 @@ func (c Collector) Collect(ctx context.Context, project, base string, decls []De
// refusal leaves nothing behind; the bytes actually streamed are held to the
// same budget.
func (c Collector) CollectRoot(ctx context.Context, project string, root *os.Root, decls []Decl) ([]Staged, error) {
out, err := c.collect(ctx, project, root, decls)
return out, c.audited(ctx, err)
}

func (c Collector) collect(ctx context.Context, project string, root *os.Root, decls []Decl) ([]Staged, error) {
plan, err := c.plan(ctx, root, decls)
if err != nil {
return nil, err
Expand Down
27 changes: 26 additions & 1 deletion free/ci/internal/artifacts/records/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ package records
// record whose blob is gone is reported missing (and remembered) and Get
// refuses it (E712 missing_blob); Get writes only inside a directory that
// authz.OpenArtifactGetDest pinned, creates every file exclusively and
// re-verifies size and digest of what it wrote.
// re-verifies size and digest of what it wrote; with Audit set, every refused
// Get but "not found" leaves an artifact.get.refused row.

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
Expand All @@ -28,6 +30,9 @@ import (
type Reader struct {
Ledger Ledger
Blobs Blobs
// Audit, when set, records every refused Get (artifact.get.refused with the
// reason key: a destination, path or digest refusal; TM-14, TM-15).
Audit AuditSink
}

// List returns the committed records the viewer may see, newest first.
Expand Down Expand Up @@ -81,6 +86,26 @@ func (r Reader) checkBlob(ctx context.Context, rec Record) Record {

// Get writes the record's bytes under dest/<name> and returns the path.
func (r Reader) Get(ctx context.Context, v authz.Viewer, id, dest, cwd string) (string, Record, error) {
p, rec, err := r.get(ctx, v, id, dest, cwd)
if err == nil || r.Audit == nil || errors.Is(err, ErrNotFound) {
return p, rec, err
}
reason := ReasonUnreadable
var pe *authz.PathError
var re *Error
switch {
case errors.As(err, &pe):
reason = string(pe.Reason)
case errors.As(err, &re):
reason = re.Reason
}
if aerr := r.Audit(ctx, "artifacts", "artifact.get.refused", id, "reason="+reason); aerr != nil {
err = errors.Join(err, fmt.Errorf("artifact get audit: %w", aerr))
}
return p, rec, err
}

func (r Reader) get(ctx context.Context, v authz.Viewer, id, dest, cwd string) (string, Record, error) {
rec, err := r.visible(ctx, v, id)
if err != nil {
return "", Record{}, err
Expand Down
12 changes: 9 additions & 3 deletions free/ci/internal/attest/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package attest

// Purpose: Revoke(node) (Epic D13, X15): the node's evidence keys become untrusted, the evidence they
// signed is quarantined, and the caller gets the re-evaluation set (gates, posted statuses, promoted
// releases). The statuses are re-posted as errors with reason node.revoked (P7-HOST-02's Repost), and
// each promoted release gets one audit event, so a revoked node's success does not stand.
// releases). The statuses are re-posted as errors with reason node.revoked (P7-HOST-02's Repost), each
// quarantined run gets an evidence.quarantined event (reason key_revoked) and each promoted release one
// release.quarantine event, so a revoked node's success does not stand.
// Inputs: the node id, the actor, a RevokeLedger, an AuditSink and a Reposter (all injected: this package
// cannot import store, audit or report).
// Outputs: Affected; errors from the audit and repost steps are joined and returned with it.
Expand Down Expand Up @@ -33,7 +34,9 @@ type ReleaseRef struct{ RunID, Project, Revision, ManifestSHA256 string }
// Affected is what a revocation re-evaluates.
type Affected struct {
// Keys are the evidence key ids revoked (ci-node-<16 hex>): those the node pins and every one it used.
Keys []string
Keys []string
// Runs are the runs whose evidence the revocation quarantined.
Runs []string
Gates []GateRef
Statuses []StatusKey
Releases []ReleaseRef
Expand Down Expand Up @@ -74,6 +77,9 @@ func (r Revoker) Revoke(ctx context.Context, nodeID, actor string) (Affected, er
if r.Audit != nil {
errs = append(errs, r.Audit(ctx, actor, "evidence.revoke", nodeID,
fmt.Sprintf("keys=%d gates=%d statuses=%d releases=%d", len(out.Keys), len(out.Gates), len(out.Statuses), len(out.Releases))))
for _, run := range out.Runs { // TM-20: each quarantined run's evidence is refused from now on
errs = append(errs, r.Audit(ctx, actor, "evidence.quarantined", run, "reason="+string(RemoteKeyRevoked)+" node="+nodeID))
}
for _, rel := range out.Releases {
errs = append(errs, r.Audit(ctx, actor, "release.quarantine", rel.RunID,
fmt.Sprintf("node=%s project=%s revision=%s manifest=%s", nodeID, rel.Project, rel.Revision, rel.ManifestSHA256)))
Expand Down
16 changes: 11 additions & 5 deletions free/ci/internal/blob/transfer/receiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ type Receiver struct {
// Live returns nil while the lease epoch is the current one. An error wrapping ErrStale is a
// stale epoch (refused, audited); any other error (a store fault) is a transient failure.
Live func(ctx context.Context) error
// Refused is told once per refused blob (by digest) when the epoch is stale, so the caller can
// write its audit row. It must not block.
// Refused is told once per refused blob and reason (by digest): a stale epoch, and a blob whose
// bytes end short or fail their digest at publish (TM-13, TM-15), so the caller can write its
// audit row. It must not block.
Refused func(ctx context.Context, sha string, err *Error)

mu sync.Mutex
Expand All @@ -83,13 +84,18 @@ func (r *Receiver) epoch(ctx context.Context, sha string) error {
if !errors.Is(err, ErrStale) {
return Failf(ReasonInterrupted, "the lease check failed: %v", err)
}
e := Failf(ReasonStaleEpoch, "the lease epoch is no longer current; blob %s is refused", short(sha))
return r.tell(ctx, sha, Failf(ReasonStaleEpoch, "the lease epoch is no longer current; blob %s is refused", short(sha)))
}

// tell reports e to Refused once per blob and reason, and returns it.
func (r *Receiver) tell(ctx context.Context, sha string, e *Error) *Error {
r.mu.Lock()
first := !r.told[sha]
k := sha + "/" + e.Reason
first := !r.told[k]
if r.told == nil {
r.told = map[string]bool{}
}
r.told[sha] = true
r.told[k] = true
r.mu.Unlock()
if first && r.Refused != nil {
r.Refused(ctx, sha, e)
Expand Down
6 changes: 3 additions & 3 deletions free/ci/internal/blob/transfer/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,17 @@ func (w *Writer) publish() error {
return Failf(ReasonProtocol, "blob %s was aborted", short(w.h.SHA256))
}
if w.offset != w.h.Size {
return Failf(ReasonInterrupted, "blob %s holds %d of %d B", short(w.h.SHA256), w.offset, w.h.Size)
return w.r.tell(context.Background(), w.h.SHA256, Failf(ReasonInterrupted, "blob %s holds %d of %d B", short(w.h.SHA256), w.offset, w.h.Size))
}
if err := w.r.epoch(context.Background(), w.h.SHA256); err != nil {
return err
}
if err := w.p.Finalize(); err != nil {
if errors.Is(err, cas.ErrPartialConflict) {
w.offset = 0 // the partial is gone: a retry starts clean
return Failf(ReasonDigest, "blob %s does not match its digest; nothing was published", short(w.h.SHA256))
return w.r.tell(context.Background(), w.h.SHA256, Failf(ReasonDigest, "blob %s does not match its digest; nothing was published", short(w.h.SHA256)))
}
return Failf(ReasonInterrupted, "blob %s could not be published: %v", short(w.h.SHA256), err)
return w.r.tell(context.Background(), w.h.SHA256, Failf(ReasonInterrupted, "blob %s could not be published: %v", short(w.h.SHA256), err))
}
w.done, w.published = true, true
return nil
Expand Down
16 changes: 13 additions & 3 deletions free/ci/internal/cache/entries/codes_entries.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,32 @@ const (
ReasonSizeCap = "size_cap"
ReasonDiskFloor = "disk_floor"
ReasonPathOutside = "path_outside"
// ReasonSecretMatch: a save the value scan refused, or a read-only snapshot
// whose writer received secrets (refused to every other reader, TM-12).
ReasonSecretMatch = "secret_match"
// ReasonBlobInvalid extends the Epic D20 list: a blob is missing, has the wrong
// size or was not freshly Put when its entry commits.
// size or was not freshly Put when its entry commits, or a restore found it
// missing, of the wrong size or failing its digest (TM-15).
ReasonBlobInvalid = "blob_invalid"
)

// Refused is E711; Reason is one of the Reason* keys.
type Refused struct {
Reason string
Err error // the cause, when there is one (ErrBlobStale, os.ErrNotExist)
// Op names the refused operation in the message; empty means "write".
Op string
}

func (e *Refused) Error() string {
op := e.Op
if op == "" {
op = "write"
}
if e.Err != nil {
return CodeCacheRefused + " cache write refused: " + e.Reason + ": " + e.Err.Error()
return CodeCacheRefused + " cache " + op + " refused: " + e.Reason + ": " + e.Err.Error()
}
return CodeCacheRefused + " cache write refused: " + e.Reason
return CodeCacheRefused + " cache " + op + " refused: " + e.Reason
}

// Unwrap exposes the cause.
Expand Down
Loading
Loading