diff --git a/README.md b/README.md index b1d3a0a..688b4be 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Environment variables (prefix `WHENCE_`): | `age` / `rage` | encrypt, decrypt | | `git` | push, pull, fetch, clone, signed commit | | `gpg` / `gpg2` | sign, decrypt, encrypt, verify | +| `cosign` | sign, sign-blob, attest (PIV / PKCS#11) | | `ssh` / `scp` / `sftp` | authenticate | | browsers | WebAuthn / passkey | diff --git a/e2e/README.md b/e2e/README.md index ed33d27..9f593b3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -43,6 +43,7 @@ a PASS / FAIL / SKIP matrix and exits non-zero if anything failed. | `ssh` | `ssh-keygen -t ed25519-sk` | FIDO2 PIN set on the key | | `age` | `age -d` via `age-plugin-yubikey` | PIV identity (best-effort) | | browser | opens webauthn.io in your default browser | a passkey/WebAuthn credential | +| `cosign` | `cosign sign-blob --key ` | PIV key via `WHENCE_E2E_COSIGN_KEY` (PKCS#11) | The watcher is granted only the eBPF caps (`cap_bpf`, `cap_perfmon`, `cap_sys_admin`). An agent-mediated touch (gpg, pass, sops, …) is attributed to @@ -64,6 +65,7 @@ Tools whose credential isn't present are **SKIP**ped with a reason. | `WHENCE_E2E_GPG_KEY` | first secret key | GPG key fingerprint to use | | `E2E_TOUCH_TIMEOUT` | `60` | seconds to wait for each touch | | `E2E_DEBUG` | `0` | `1` runs the watcher with `-verbose` and prints, per test, the full process call stack the classifier saw (plus how the gpg/ssh-agent client was resolved) — use it to explain a misclassification | +| `WHENCE_E2E_COSIGN_KEY` | _(unset)_ | PIV PKCS#11 key URI for the cosign test (skipped if unset) | ## Requirements diff --git a/e2e/run.sh b/e2e/run.sh index 242e9ab..abf82b3 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -309,8 +309,22 @@ test_browser() { show_stack } +test_cosign() { + command -v cosign >/dev/null || { record cosign SKIP "cosign not installed"; return; } + local key="${WHENCE_E2E_COSIGN_KEY:-}" + [ -n "$key" ] || { record cosign SKIP "set WHENCE_E2E_COSIGN_KEY to a PIV PKCS#11 key URI (touch-policy=always)"; return; } + ask_run "cosign — sign a blob with your PIV key ($key)" || { record cosign SKIP "skipped"; return; } + printf 'whence-touche-e2e\n' > "$WORK/cosign-blob.txt" + touch_now; mark + if timeout "$TOUCH_TIMEOUT" cosign sign-blob --yes --key "$key" "$WORK/cosign-blob.txt" >"$WORK/cosign.log" 2>&1; then + finish cosign cosign + else + record cosign FAIL "cosign sign-blob failed/timed out (see $WORK/cosign.log)" + fi +} + # --- driver ------------------------------------------------------------------- -ALL=(gpg pass gopass sops git ssh age browser) +ALL=(gpg pass gopass sops git ssh age browser cosign) if [ "$#" -gt 0 ]; then SELECTED=("$@"); else SELECTED=("${ALL[@]}"); fi say "Testing: ${SELECTED[*]}" diff --git a/flake.nix b/flake.nix index f9266c3..d621a95 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,7 @@ pkgs.age # age pkgs.rage # rage pkgs.git # git + pkgs.cosign # cosign (Sigstore signing) pkgs.yubikey-manager # ykman (key diagnostics) pkgs.age-plugin-yubikey # age + YubiKey via PIV pkgs.libfido2 # fido2-token etc. for FIDO diagnostics diff --git a/internal/classifier/rules/all.go b/internal/classifier/rules/all.go index 74bdf43..4eb2f93 100644 --- a/internal/classifier/rules/all.go +++ b/internal/classifier/rules/all.go @@ -14,6 +14,7 @@ func All() []classifier.Rule { Age{}, Git{}, GPG{}, + Cosign{}, Browser{}, SSH{}, } diff --git a/internal/classifier/rules/cosign.go b/internal/classifier/rules/cosign.go new file mode 100644 index 0000000..4afae27 --- /dev/null +++ b/internal/classifier/rules/cosign.go @@ -0,0 +1,93 @@ +package rules + +import ( + "strings" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +// Cosign matches cosign (Sigstore) signing operations. +// https://docs.sigstore.dev/cosign/ +// +// cosign can sign with a PIV hardware token (touch-policy=always) via its +// go-piv / PKCS#11 backend, so a sustained touch-wait while cosign runs is a +// hardware-key signing request. +type Cosign struct{} + +func (Cosign) Match(tree []classifier.Process) (classifier.Classification, bool) { + idx, p, ok := classifier.FindFirst(tree, "cosign") + if !ok { + return classifier.Classification{}, false + } + action, resource := cosignOperation(p) + return classifier.Classification{ + Tool: "cosign", + Action: action, + Resource: resource, + Depth: idx, + }, true +} + +func cosignOperation(p classifier.Process) (action, resource string) { + sub, pos := parseCosignArgs(p) + + switch sub { + case "sign": + action = "sign" + resource = pos + if resource == "" { + resource = "artifact" + } + case "sign-blob": + action = "sign blob" + resource = pos + if resource == "" { + if key, ok := classifier.Arg(p, "--key"); ok { + resource = key + } else { + resource = "blob" + } + } + case "attest": + action = "attest" + resource = pos + if resource == "" { + resource = "artifact" + } + case "generate-key-pair": + action = "generate key" + resource = "PIV key" + default: + action = "sign" + resource = "artifact" + } + return +} + +// parseCosignArgs returns the first subcommand token (the first non-flag arg +// after argv[0]) and the first positional that follows it. A bare flag (e.g. +// "--key pkcs11:...") consumes the next token as its value so it is not +// mistaken for a positional. +func parseCosignArgs(p classifier.Process) (sub, pos string) { + skip := false + for _, arg := range p.Args[1:] { + if skip { + skip = false + continue + } + if strings.HasPrefix(arg, "-") { + // "--flag=value" is self-contained; "--flag value" eats the next token. + if !strings.Contains(arg, "=") { + skip = true + } + continue + } + if sub == "" { + sub = arg + continue + } + pos = arg + return + } + return +} diff --git a/internal/classifier/rules/cosign_test.go b/internal/classifier/rules/cosign_test.go new file mode 100644 index 0000000..40fb32a --- /dev/null +++ b/internal/classifier/rules/cosign_test.go @@ -0,0 +1,72 @@ +package rules + +import ( + "testing" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +func TestCosignMatch(t *testing.T) { + cases := []struct { + name string + comm string + args []string + wantOK bool + wantTool string + wantAction string + wantResource string + wantDepth int + }{ + { + name: "sign image reference", + comm: "cosign", + args: []string{"cosign", "sign", "ghcr.io/acme/app:1.0"}, + wantOK: true, + wantTool: "cosign", + wantAction: "sign", + wantResource: "ghcr.io/acme/app:1.0", + wantDepth: 0, + }, + { + name: "sign-blob with key flag and file", + comm: "cosign", + args: []string{"cosign", "sign-blob", "--key", "pkcs11:object=signing", "artifact.tar"}, + wantOK: true, + wantTool: "cosign", + wantAction: "sign blob", + wantResource: "artifact.tar", + wantDepth: 0, + }, + { + name: "no match", + comm: "bash", + args: []string{"bash", "-c", "echo hi"}, + wantOK: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tree := []classifier.Process{{PID: 100, Comm: tc.comm, Args: tc.args}} + got, ok := Cosign{}.Match(tree) + if ok != tc.wantOK { + t.Fatalf("Match ok = %v, want %v", ok, tc.wantOK) + } + if !tc.wantOK { + return + } + if got.Tool != tc.wantTool { + t.Errorf("Tool = %q, want %q", got.Tool, tc.wantTool) + } + if got.Action != tc.wantAction { + t.Errorf("Action = %q, want %q", got.Action, tc.wantAction) + } + if got.Resource != tc.wantResource { + t.Errorf("Resource = %q, want %q", got.Resource, tc.wantResource) + } + if got.Depth != tc.wantDepth { + t.Errorf("Depth = %d, want %d", got.Depth, tc.wantDepth) + } + }) + } +}