Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 2 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkcs11>` | 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
Expand All @@ -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

Expand Down
16 changes: 15 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[*]}"
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/classifier/rules/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func All() []classifier.Rule {
Age{},
Git{},
GPG{},
Cosign{},
Browser{},
SSH{},
}
Expand Down
93 changes: 93 additions & 0 deletions internal/classifier/rules/cosign.go
Original file line number Diff line number Diff line change
@@ -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
}
72 changes: 72 additions & 0 deletions internal/classifier/rules/cosign_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}