Skip to content
Merged
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
5 changes: 5 additions & 0 deletions cmd/sjskills/default_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"reflect"
"runtime"
"strings"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -202,6 +203,10 @@ func TestDefaultStatusNestedAndUnsafeConfiguration(t *testing.T) {
err = os.Mkdir(manifest, 0755)
} else {
err = os.Symlink(filepath.Join(f.project, "absent"), manifest)
// ERROR_PRIVILEGE_NOT_HELD: no Developer Mode or elevation.
if runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1314)) {
t.Skipf("symlinks unavailable: %v", err)
}
}
if err != nil {
t.Fatal(err)
Expand Down
8 changes: 8 additions & 0 deletions internal/sjskills/classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,14 @@ func canonicalProjectSourceIdentity(source string) (string, bool) {
return "https://" + hostPort + cleanPath, true
}

// isCanonicalSourceIdentityForScope additionally refuses local identities in
// global scope, whose desired sources are always remote. Restore uses it so a
// tampered global quarantine cannot reintroduce provenance that global
// inventory would then distrust.
func isCanonicalSourceIdentityForScope(scope Scope, identity string) bool {
return isCanonicalProjectSourceIdentity(identity) && (scope != ScopeGlobal || !isCanonicalLocalSourceIdentity(identity))
}

func isCanonicalProjectSourceIdentity(identity string) bool {
if strings.HasPrefix(identity, localSourceIdentityPrefix) {
return isCanonicalLocalSourceIdentity(identity)
Expand Down
18 changes: 18 additions & 0 deletions internal/sjskills/local_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,21 @@ func TestLocalOnlyStatusDoesNotDependOnUpstreamCache(t *testing.T) {
}
requireStatusFinding(t, advisory, AdvisoryMissing, "team-tool", TargetAgents, string(ProjectStateReasonExpectedEntryAbsent))
}

func TestRestoreRejectsLocalIdentityInGlobalScope(t *testing.T) {
if !isCanonicalSourceIdentityForScope(ScopeProject, "local:./skills/team-tool") ||
isCanonicalSourceIdentityForScope(ScopeGlobal, "local:./skills/team-tool") ||
!isCanonicalSourceIdentityForScope(ScopeGlobal, "github:owner/repo") {
t.Fatal("scope-aware identity validation is wrong")
}
entry := restoreEntry{
entry: ProjectQuarantineManifestEntry{Skill: "team-tool", Target: TargetAgents, Action: ProjectQuarantineEntryActionRemove, OldSourceIdentity: "local:./skills/team-tool"},
oldHash: classificationHash('a'),
}
if _, err := buildRestoreProvenanceState(ProvenanceState{}, []restoreEntry{entry}, time.Now(), ScopeGlobal); err == nil {
t.Fatal("global restore accepted a local source identity")
}
if _, err := buildRestoreProvenanceState(ProvenanceState{}, []restoreEntry{entry}, time.Now(), ScopeProject); err != nil {
t.Fatalf("project restore rejected a local source identity: %v", err)
}
}
8 changes: 4 additions & 4 deletions internal/sjskills/project_restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ func (tx *restoreTransaction) preflight(ctx context.Context) error {
state.state = emptyTransactionProvenanceState(tx.scope)
}
for _, record := range state.state.Records {
if !isCanonicalProjectSourceIdentity(record.SourceIdentity) {
if !isCanonicalSourceIdentityForScope(tx.scope, record.SourceIdentity) {
return restoreConflict("provenance identity is incompatible")
}
}
Expand Down Expand Up @@ -596,7 +596,7 @@ func validateRestoreProvenance(state ProvenanceState, entry ProjectQuarantineMan
}
if !exists || record.Scope != scope || record.Target != entry.Target || record.Skill != entry.Skill ||
record.SourceIdentity != entry.NewSourceIdentity || record.TreeHashAlgorithm != entry.TreeHashAlgorithm || record.TreeHash != entry.NewTreeHash ||
!isCanonicalProjectSourceIdentity(record.SourceIdentity) {
!isCanonicalSourceIdentityForScope(scope, record.SourceIdentity) {
return restoreConflict("update provenance does not match the manifest")
}
return nil
Expand All @@ -610,7 +610,7 @@ func validateRestoreProvenance(state ProvenanceState, entry ProjectQuarantineMan
}
if !exists || record.Scope != scope || record.Target != entry.Target || record.Skill != entry.Skill ||
record.SourceIdentity != oldSource || record.TreeHashAlgorithm != entry.TreeHashAlgorithm || record.TreeHash != entry.OldTreeHash ||
!isCanonicalProjectSourceIdentity(record.SourceIdentity) {
!isCanonicalSourceIdentityForScope(scope, record.SourceIdentity) {
return restoreConflict("restored provenance does not match the manifest")
}
return nil
Expand Down Expand Up @@ -1016,7 +1016,7 @@ func buildRestoreProvenanceState(previous ProvenanceState, entries []restoreEntr
delete(records, key)
continue
}
if !isCanonicalProjectSourceIdentity(entry.entry.OldSourceIdentity) {
if !isCanonicalSourceIdentityForScope(scope, entry.entry.OldSourceIdentity) {
return ProvenanceState{}, restoreConflict("quarantine source identity is incompatible")
}
records[projectPlacementKey(entry.entry.Target, entry.entry.Skill)] = ProvenanceRecord{
Expand Down
Loading