The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
467 lines
15 KiB
Go
467 lines
15 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package console_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"go.gearno.de/kit/pg"
|
|
"go.probo.inc/probo/e2e/internal/factory"
|
|
"go.probo.inc/probo/e2e/internal/testutil"
|
|
"go.probo.inc/probo/internal/test"
|
|
)
|
|
|
|
// injectCrossTenantFK bypasses the application entirely and writes a foreign
|
|
// key directly via SQL against the same Postgres database the e2e probod
|
|
// instance is running against. This simulates a cross-tenant reference that
|
|
// exists in the row despite the application's own write-time validation --
|
|
// e.g. a future regression in that specific check, a data migration bug, or
|
|
// direct DB access -- so that the read-path fix (GHSA-c74x-79w6-63jh pattern
|
|
// 2: authorizing the actual child id, not the parent's) can be verified on
|
|
// its own, independently of whether the write-time check is still in place.
|
|
func injectCrossTenantFK(t *testing.T, table, column, rowID, foreignID string) {
|
|
t.Helper()
|
|
|
|
client := test.PGClient(t)
|
|
ctx := context.Background()
|
|
|
|
err := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
|
_, err := conn.Exec(ctx, `UPDATE `+table+` SET `+column+` = $1 WHERE id = $2`, foreignID, rowID)
|
|
return err
|
|
})
|
|
require.NoError(t, err, "test setup: cannot inject cross-tenant FK into %s.%s", table, column)
|
|
}
|
|
|
|
// TestSecurity_ReadGap_FindingRisk independently verifies the fix for
|
|
// GHSA-c74x-79w6-63jh's confirmed exploit #1. findingResolver.Risk now
|
|
// authorizes obj.Risk.ID instead of obj.ID; this test proves that holds even
|
|
// when findings.risk_id points at another organization's risk by means other
|
|
// than FindingService.Create/Update (which is separately validated and
|
|
// covered by TestFinding_TenantIsolation).
|
|
func TestSecurity_ReadGap_FindingRisk(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
org2RiskID := factory.CreateRisk(org2Owner, factory.Attrs{"name": "Org2 Secret Risk (read-gap probe)"})
|
|
|
|
var createResult struct {
|
|
CreateFinding struct {
|
|
FindingEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"findingEdge"`
|
|
} `json:"createFinding"`
|
|
}
|
|
|
|
err := org1Owner.Execute(`
|
|
mutation($input: CreateFindingInput!) {
|
|
createFinding(input: $input) {
|
|
findingEdge { node { id } }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": org1Owner.GetOrganizationID().String(),
|
|
"kind": "OBSERVATION",
|
|
"status": "OPEN",
|
|
"priority": "LOW",
|
|
},
|
|
}, &createResult)
|
|
require.NoError(t, err)
|
|
|
|
findingID := createResult.CreateFinding.FindingEdge.Node.ID
|
|
|
|
injectCrossTenantFK(t, "findings", "risk_id", findingID, org2RiskID)
|
|
|
|
var readResult struct {
|
|
Node struct {
|
|
Risk *struct {
|
|
ID string `json:"id"`
|
|
} `json:"risk"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = org1Owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Finding {
|
|
risk { id }
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": findingID}, &readResult)
|
|
|
|
testutil.AssertNodeNotAccessible(t, err, readResult.Node.Risk == nil, "cross-tenant risk via finding.risk")
|
|
}
|
|
|
|
// TestSecurity_ReadGap_ProcessingActivityDataProtectionOfficer independently
|
|
// verifies the fix for GHSA-c74x-79w6-63jh's confirmed exploit #2 (PII
|
|
// disclosure). processingActivityResolver.DataProtectionOfficer now
|
|
// authorizes obj.DataProtectionOfficer.ID instead of obj.ID.
|
|
func TestSecurity_ReadGap_ProcessingActivityDataProtectionOfficer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret DPO (read-gap probe)"})
|
|
|
|
paID := factory.NewProcessingActivity(org1Owner).WithName("Org1 PA for read-gap probe").Create()
|
|
|
|
injectCrossTenantFK(t, "processing_activities", "dpo_profile_id", paID, org2ProfileID)
|
|
|
|
var readResult struct {
|
|
Node struct {
|
|
DataProtectionOfficer *struct {
|
|
ID string `json:"id"`
|
|
FullName string `json:"fullName"`
|
|
} `json:"dataProtectionOfficer"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := org1Owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on ProcessingActivity {
|
|
dataProtectionOfficer { id fullName }
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": paID}, &readResult)
|
|
|
|
testutil.AssertNodeNotAccessible(t, err, readResult.Node.DataProtectionOfficer == nil, "cross-tenant profile PII via processingActivity.dataProtectionOfficer")
|
|
}
|
|
|
|
// TestSecurity_ReadGap_AuditFramework independently verifies the
|
|
// defense-in-depth fix for auditResolver.Framework, which now authorizes
|
|
// obj.Framework.ID instead of obj.ID.
|
|
func TestSecurity_ReadGap_AuditFramework(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
org1FrameworkID := factory.CreateFramework(org1Owner)
|
|
auditID := factory.CreateAudit(org1Owner, org1FrameworkID)
|
|
|
|
org2FrameworkID := factory.CreateFramework(org2Owner, factory.Attrs{"name": "Org2 Secret Framework (read-gap probe)"})
|
|
|
|
injectCrossTenantFK(t, "audits", "framework_id", auditID, org2FrameworkID)
|
|
|
|
var readResult struct {
|
|
Node struct {
|
|
Framework *struct {
|
|
ID string `json:"id"`
|
|
} `json:"framework"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := org1Owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on Audit {
|
|
framework { id }
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": auditID}, &readResult)
|
|
|
|
testutil.AssertNodeNotAccessible(t, err, readResult.Node.Framework == nil, "cross-tenant framework via audit.framework")
|
|
}
|
|
|
|
// TestSecurity_ReadGap_ApplicabilityStatementControl independently verifies
|
|
// the defense-in-depth fix for applicabilityStatementResolver.Control, which
|
|
// now authorizes obj.Control.ID instead of obj.ID.
|
|
func TestSecurity_ReadGap_ApplicabilityStatementControl(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
soaID := factory.NewStatementOfApplicability(org1Owner).Create()
|
|
org1FrameworkID := factory.CreateFramework(org1Owner)
|
|
org1ControlID := factory.CreateControl(org1Owner, org1FrameworkID)
|
|
asID := factory.CreateApplicabilityStatement(org1Owner, soaID, org1ControlID, true, nil)
|
|
|
|
org2FrameworkID := factory.CreateFramework(org2Owner)
|
|
org2ControlID := factory.CreateControl(org2Owner, org2FrameworkID, factory.Attrs{"name": "Org2 Secret Control (read-gap probe)"})
|
|
|
|
injectCrossTenantFK(t, "applicability_statements", "control_id", asID, org2ControlID)
|
|
|
|
// ApplicabilityStatement is not reachable via node(id:...) directly (a
|
|
// separate, unrelated gap: coredata.ApplicabilityStatementEntityType has
|
|
// no case in queryResolver.Node's dispatch switch, so it always denies
|
|
// with an empty action regardless of tenant). Reach the same
|
|
// applicabilityStatementResolver.Control sub-resolver through
|
|
// StatementOfApplicability.applicabilityStatements instead, which does
|
|
// implement Node.
|
|
var readResult struct {
|
|
Node struct {
|
|
ApplicabilityStatements struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Control *struct {
|
|
ID string `json:"id"`
|
|
} `json:"control"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"applicabilityStatements"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := org1Owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on StatementOfApplicability {
|
|
applicabilityStatements(first: 10) {
|
|
edges {
|
|
node {
|
|
id
|
|
control { id }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": soaID}, &readResult)
|
|
|
|
// control: Control! is non-nullable, so a denial on that field nulls the
|
|
// whole ancestor chain up to the query's nullable "node" root -- err != nil
|
|
// with no edges IS the expected block, same as AssertNodeNotAccessible's
|
|
// contract elsewhere in this suite.
|
|
if err == nil {
|
|
var got *struct {
|
|
ID string `json:"id"`
|
|
}
|
|
for _, edge := range readResult.Node.ApplicabilityStatements.Edges {
|
|
if edge.Node.ID == asID {
|
|
got = edge.Node.Control
|
|
}
|
|
}
|
|
|
|
require.Nil(t, got, "must not be able to read a cross-tenant control via applicabilityStatement.control")
|
|
}
|
|
}
|
|
|
|
// TestSecurity_ReadGap_DocumentVersionSignatureSignedBy independently
|
|
// verifies the defense-in-depth fix for documentVersionSignatureResolver.SignedBy,
|
|
// which now authorizes obj.SignedBy.ID instead of obj.ID.
|
|
func TestSecurity_ReadGap_DocumentVersionSignatureSignedBy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, org1Owner)
|
|
approveTestDocument(t, org1Owner, docID)
|
|
versionID := latestDocumentVersionID(t, org1Owner, docID)
|
|
org1ProfileID := getOwnerProfileID(t, org1Owner)
|
|
|
|
var sigResult struct {
|
|
RequestSignature struct {
|
|
DocumentVersionSignatureEdge struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"documentVersionSignatureEdge"`
|
|
} `json:"requestSignature"`
|
|
}
|
|
|
|
err := org1Owner.Execute(`
|
|
mutation($input: RequestSignatureInput!) {
|
|
requestSignature(input: $input) {
|
|
documentVersionSignatureEdge { node { id } }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"documentVersionId": versionID,
|
|
"signatoryId": org1ProfileID,
|
|
},
|
|
}, &sigResult)
|
|
require.NoError(t, err)
|
|
|
|
signatureID := sigResult.RequestSignature.DocumentVersionSignatureEdge.Node.ID
|
|
|
|
org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Signatory (read-gap probe)"})
|
|
|
|
injectCrossTenantFK(t, "document_version_signatures", "signed_by_profile_id", signatureID, org2ProfileID)
|
|
|
|
var readResult struct {
|
|
Node struct {
|
|
SignedBy *struct {
|
|
ID string `json:"id"`
|
|
FullName string `json:"fullName"`
|
|
} `json:"signedBy"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = org1Owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on DocumentVersionSignature {
|
|
signedBy { id fullName }
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": signatureID}, &readResult)
|
|
|
|
testutil.AssertNodeNotAccessible(t, err, readResult.Node.SignedBy == nil, "cross-tenant profile PII via documentVersionSignature.signedBy")
|
|
}
|
|
|
|
// TestSecurity_ReadGap_DocumentVersionApprovalDecisionApprover independently
|
|
// verifies a read-gap found while auditing GHSA-c74x-79w6-63jh's blast
|
|
// radius: documentVersionApprovalDecisionResolver.Approver authorized
|
|
// obj.ID (the decision, caller's own org) instead of obj.Approver.ID, and
|
|
// the profile loader it called into (iam.OrganizationService.GetProfile)
|
|
// derives its scope from coredata.NewScopeFromObjectID(profileID) -- the
|
|
// exact same scope-from-attacker-key mechanism as the two confirmed GHSA
|
|
// exploits. Not independently exploitable today because the write path
|
|
// (DocumentApprovalService.createDecisions, reached via
|
|
// validateApproverProfileIDs in document_service.go/generated_document_service.go)
|
|
// already rejects a cross-tenant approverId, but this proves the read-side
|
|
// fix (authorizing obj.Approver.ID) holds on its own.
|
|
func TestSecurity_ReadGap_DocumentVersionApprovalDecisionApprover(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
docID, _ := createTestDocument(t, org1Owner)
|
|
org1ProfileID := getOwnerProfileID(t, org1Owner)
|
|
requestDocumentApproval(t, org1Owner, docID, []string{org1ProfileID})
|
|
versionID := latestDocumentVersionID(t, org1Owner, docID)
|
|
|
|
var quorumResult struct {
|
|
Node struct {
|
|
ApprovalQuorums struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Decisions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"decisions"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"approvalQuorums"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err := org1Owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on DocumentVersion {
|
|
approvalQuorums(first: 10) {
|
|
edges {
|
|
node {
|
|
id
|
|
decisions(first: 10) {
|
|
edges { node { id } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": versionID}, &quorumResult)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, quorumResult.Node.ApprovalQuorums.Edges)
|
|
require.NotEmpty(t, quorumResult.Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges)
|
|
|
|
decisionID := quorumResult.Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges[0].Node.ID
|
|
|
|
org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Approver Profile"})
|
|
|
|
injectCrossTenantFK(t, "document_version_approval_decisions", "approver_id", decisionID, org2ProfileID)
|
|
|
|
var readResult struct {
|
|
Node struct {
|
|
ApprovalQuorums struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
Decisions struct {
|
|
Edges []struct {
|
|
Node struct {
|
|
ID string `json:"id"`
|
|
Approver *struct {
|
|
ID string `json:"id"`
|
|
FullName string `json:"fullName"`
|
|
} `json:"approver"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"decisions"`
|
|
} `json:"node"`
|
|
} `json:"edges"`
|
|
} `json:"approvalQuorums"`
|
|
} `json:"node"`
|
|
}
|
|
|
|
err = org1Owner.Execute(`
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on DocumentVersion {
|
|
approvalQuorums(first: 10) {
|
|
edges {
|
|
node {
|
|
decisions(first: 10) {
|
|
edges { node { id approver { id fullName } } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`, map[string]any{"id": versionID}, &readResult)
|
|
|
|
// approver: Profile! is non-nullable, so a denial on that field nulls the
|
|
// whole ancestor chain up to the query's nullable "node" root -- err !=
|
|
// nil with no edges IS the expected block.
|
|
if err == nil {
|
|
var got *struct {
|
|
ID string `json:"id"`
|
|
FullName string `json:"fullName"`
|
|
}
|
|
|
|
for _, qEdge := range readResult.Node.ApprovalQuorums.Edges {
|
|
for _, dEdge := range qEdge.Node.Decisions.Edges {
|
|
if dEdge.Node.ID == decisionID {
|
|
got = dEdge.Node.Approver
|
|
}
|
|
}
|
|
}
|
|
|
|
require.Nil(t, got, "must not be able to read a cross-tenant profile via documentVersionApprovalDecision.approver")
|
|
}
|
|
}
|