Add comprehensive security tests for OWASP A02, A05, A07, and A08 categories
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled

- Implemented tests for Cryptographic Failures (A02) to ensure proper handling of sensitive data, secure algorithms, and key management.
- Added tests for Security Misconfiguration (A05) to validate production configurations, security headers, CORS settings, and feature management.
- Developed tests for Authentication Failures (A07) to enforce strong password policies, rate limiting, session management, and MFA support.
- Created tests for Software and Data Integrity Failures (A08) to verify artifact signatures, SBOM integrity, attestation chains, and feed updates.
This commit is contained in:
master
2025-12-16 16:40:19 +02:00
parent 415eff1207
commit 2170a58734
206 changed files with 30547 additions and 534 deletions

View File

@@ -0,0 +1,60 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace StellaOps.Attestor.Persistence.Entities;
/// <summary>
/// Audit log entry for proof chain operations.
/// Maps to proofchain.audit_log table.
/// </summary>
[Table("audit_log", Schema = "proofchain")]
public class AuditLogEntity
{
/// <summary>
/// Primary key - auto-generated UUID.
/// </summary>
[Key]
[Column("log_id")]
public Guid LogId { get; set; }
/// <summary>
/// The operation performed (e.g., "create", "verify", "revoke").
/// </summary>
[Required]
[Column("operation")]
public string Operation { get; set; } = null!;
/// <summary>
/// The type of entity affected (e.g., "sbom_entry", "spine", "trust_anchor").
/// </summary>
[Required]
[Column("entity_type")]
public string EntityType { get; set; } = null!;
/// <summary>
/// The ID of the affected entity.
/// </summary>
[Required]
[Column("entity_id")]
public string EntityId { get; set; } = null!;
/// <summary>
/// The actor who performed the operation (user, service, etc.).
/// </summary>
[Column("actor")]
public string? Actor { get; set; }
/// <summary>
/// Additional details about the operation.
/// </summary>
[Column("details", TypeName = "jsonb")]
public JsonDocument? Details { get; set; }
/// <summary>
/// When this log entry was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,80 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Attestor.Persistence.Entities;
/// <summary>
/// Signed DSSE envelope for proof chain statements.
/// Maps to proofchain.dsse_envelopes table.
/// </summary>
[Table("dsse_envelopes", Schema = "proofchain")]
public class DsseEnvelopeEntity
{
/// <summary>
/// Primary key - auto-generated UUID.
/// </summary>
[Key]
[Column("env_id")]
public Guid EnvId { get; set; }
/// <summary>
/// Reference to the SBOM entry this envelope relates to.
/// </summary>
[Required]
[Column("entry_id")]
public Guid EntryId { get; set; }
/// <summary>
/// Predicate type URI (e.g., evidence.stella/v1).
/// </summary>
[Required]
[Column("predicate_type")]
public string PredicateType { get; set; } = null!;
/// <summary>
/// Key ID that signed this envelope.
/// </summary>
[Required]
[Column("signer_keyid")]
public string SignerKeyId { get; set; } = null!;
/// <summary>
/// SHA-256 hash of the envelope body.
/// </summary>
[Required]
[MaxLength(64)]
[Column("body_hash")]
public string BodyHash { get; set; } = null!;
/// <summary>
/// Reference to blob storage (OCI, S3, file).
/// </summary>
[Required]
[Column("envelope_blob_ref")]
public string EnvelopeBlobRef { get; set; } = null!;
/// <summary>
/// When the envelope was signed.
/// </summary>
[Column("signed_at")]
public DateTimeOffset SignedAt { get; set; }
/// <summary>
/// When this record was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; set; }
// Navigation properties
/// <summary>
/// The SBOM entry this envelope relates to.
/// </summary>
public SbomEntryEntity Entry { get; set; } = null!;
/// <summary>
/// The Rekor transparency log entry if logged.
/// </summary>
public RekorEntryEntity? RekorEntry { get; set; }
}

View File

@@ -0,0 +1,76 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace StellaOps.Attestor.Persistence.Entities;
/// <summary>
/// Rekor transparency log entry for DSSE envelope verification.
/// Maps to proofchain.rekor_entries table.
/// </summary>
[Table("rekor_entries", Schema = "proofchain")]
public class RekorEntryEntity
{
/// <summary>
/// Primary key - SHA-256 hash of the DSSE envelope.
/// </summary>
[Key]
[MaxLength(64)]
[Column("dsse_sha256")]
public string DsseSha256 { get; set; } = null!;
/// <summary>
/// Log index in Rekor.
/// </summary>
[Required]
[Column("log_index")]
public long LogIndex { get; set; }
/// <summary>
/// Rekor log ID (tree hash).
/// </summary>
[Required]
[Column("log_id")]
public string LogId { get; set; } = null!;
/// <summary>
/// UUID of the entry in Rekor.
/// </summary>
[Required]
[Column("uuid")]
public string Uuid { get; set; } = null!;
/// <summary>
/// Unix timestamp when entry was integrated into the log.
/// </summary>
[Required]
[Column("integrated_time")]
public long IntegratedTime { get; set; }
/// <summary>
/// Merkle inclusion proof from Rekor.
/// </summary>
[Required]
[Column("inclusion_proof", TypeName = "jsonb")]
public JsonDocument InclusionProof { get; set; } = null!;
/// <summary>
/// When this record was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Reference to the DSSE envelope.
/// </summary>
[Column("env_id")]
public Guid? EnvId { get; set; }
// Navigation properties
/// <summary>
/// The DSSE envelope this entry refers to.
/// </summary>
public DsseEnvelopeEntity? Envelope { get; set; }
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Attestor.Persistence.Entities;
/// <summary>
/// SBOM component entry with content-addressed identifiers.
/// Maps to proofchain.sbom_entries table.
/// </summary>
[Table("sbom_entries", Schema = "proofchain")]
public class SbomEntryEntity
{
/// <summary>
/// Primary key - auto-generated UUID.
/// </summary>
[Key]
[Column("entry_id")]
public Guid EntryId { get; set; }
/// <summary>
/// SHA-256 hash of the parent SBOM document.
/// </summary>
[Required]
[MaxLength(64)]
[Column("bom_digest")]
public string BomDigest { get; set; } = null!;
/// <summary>
/// Package URL (PURL) of the component.
/// </summary>
[Required]
[Column("purl")]
public string Purl { get; set; } = null!;
/// <summary>
/// Component version.
/// </summary>
[Column("version")]
public string? Version { get; set; }
/// <summary>
/// SHA-256 hash of the component artifact if available.
/// </summary>
[MaxLength(64)]
[Column("artifact_digest")]
public string? ArtifactDigest { get; set; }
/// <summary>
/// Reference to the trust anchor for this entry.
/// </summary>
[Column("trust_anchor_id")]
public Guid? TrustAnchorId { get; set; }
/// <summary>
/// When this entry was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; set; }
// Navigation properties
/// <summary>
/// The trust anchor for this entry.
/// </summary>
public TrustAnchorEntity? TrustAnchor { get; set; }
/// <summary>
/// DSSE envelopes associated with this entry.
/// </summary>
public ICollection<DsseEnvelopeEntity> Envelopes { get; set; } = new List<DsseEnvelopeEntity>();
/// <summary>
/// The proof spine for this entry.
/// </summary>
public SpineEntity? Spine { get; set; }
}

View File

@@ -0,0 +1,82 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Attestor.Persistence.Entities;
/// <summary>
/// Proof spine linking evidence to verdicts via merkle aggregation.
/// Maps to proofchain.spines table.
/// </summary>
[Table("spines", Schema = "proofchain")]
public class SpineEntity
{
/// <summary>
/// Primary key - references SBOM entry.
/// </summary>
[Key]
[Column("entry_id")]
public Guid EntryId { get; set; }
/// <summary>
/// ProofBundleID (merkle root of all components).
/// </summary>
[Required]
[MaxLength(64)]
[Column("bundle_id")]
public string BundleId { get; set; } = null!;
/// <summary>
/// Array of EvidenceIDs in sorted order.
/// </summary>
[Required]
[Column("evidence_ids", TypeName = "text[]")]
public string[] EvidenceIds { get; set; } = [];
/// <summary>
/// ReasoningID for the policy evaluation.
/// </summary>
[Required]
[MaxLength(64)]
[Column("reasoning_id")]
public string ReasoningId { get; set; } = null!;
/// <summary>
/// VexVerdictID for the VEX statement.
/// </summary>
[Required]
[MaxLength(64)]
[Column("vex_id")]
public string VexId { get; set; } = null!;
/// <summary>
/// Reference to the trust anchor.
/// </summary>
[Column("anchor_id")]
public Guid? AnchorId { get; set; }
/// <summary>
/// Policy version used for evaluation.
/// </summary>
[Required]
[Column("policy_version")]
public string PolicyVersion { get; set; } = null!;
/// <summary>
/// When this spine was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; set; }
// Navigation properties
/// <summary>
/// The SBOM entry this spine covers.
/// </summary>
public SbomEntryEntity Entry { get; set; } = null!;
/// <summary>
/// The trust anchor for this spine.
/// </summary>
public TrustAnchorEntity? Anchor { get; set; }
}

View File

@@ -0,0 +1,76 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Attestor.Persistence.Entities;
/// <summary>
/// Trust anchor configuration for dependency verification.
/// Maps to proofchain.trust_anchors table.
/// </summary>
[Table("trust_anchors", Schema = "proofchain")]
public class TrustAnchorEntity
{
/// <summary>
/// Primary key - auto-generated UUID.
/// </summary>
[Key]
[Column("anchor_id")]
public Guid AnchorId { get; set; }
/// <summary>
/// PURL glob pattern (e.g., pkg:npm/*).
/// </summary>
[Required]
[Column("purl_pattern")]
public string PurlPattern { get; set; } = null!;
/// <summary>
/// Key IDs allowed to sign attestations matching this pattern.
/// </summary>
[Required]
[Column("allowed_keyids", TypeName = "text[]")]
public string[] AllowedKeyIds { get; set; } = [];
/// <summary>
/// Optional: Predicate types allowed for this anchor.
/// </summary>
[Column("allowed_predicate_types", TypeName = "text[]")]
public string[]? AllowedPredicateTypes { get; set; }
/// <summary>
/// Optional reference to the policy document.
/// </summary>
[Column("policy_ref")]
public string? PolicyRef { get; set; }
/// <summary>
/// Policy version for this anchor.
/// </summary>
[Column("policy_version")]
public string? PolicyVersion { get; set; }
/// <summary>
/// Key IDs that have been revoked but may appear in old proofs.
/// </summary>
[Column("revoked_keys", TypeName = "text[]")]
public string[] RevokedKeys { get; set; } = [];
/// <summary>
/// Whether this anchor is active.
/// </summary>
[Column("is_active")]
public bool IsActive { get; set; } = true;
/// <summary>
/// When this anchor was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// When this anchor was last updated.
/// </summary>
[Column("updated_at")]
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,159 @@
-- Migration: 20251214000001_AddProofChainSchema
-- Creates the proofchain schema and all tables for proof chain persistence.
-- This migration is idempotent and can be run multiple times safely.
-- Create schema
CREATE SCHEMA IF NOT EXISTS proofchain;
-- Create verification_result enum type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'verification_result' AND typnamespace = 'proofchain'::regnamespace) THEN
CREATE TYPE proofchain.verification_result AS ENUM ('pass', 'fail', 'pending');
END IF;
END $$;
-- 4.4 trust_anchors Table (create first - no dependencies)
CREATE TABLE IF NOT EXISTS proofchain.trust_anchors (
anchor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purl_pattern TEXT NOT NULL,
allowed_keyids TEXT[] NOT NULL,
allowed_predicate_types TEXT[],
policy_ref TEXT,
policy_version TEXT,
revoked_keys TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_trust_anchors_pattern ON proofchain.trust_anchors(purl_pattern);
CREATE INDEX IF NOT EXISTS idx_trust_anchors_active ON proofchain.trust_anchors(is_active) WHERE is_active = TRUE;
COMMENT ON TABLE proofchain.trust_anchors IS 'Trust anchor configurations for dependency verification';
COMMENT ON COLUMN proofchain.trust_anchors.purl_pattern IS 'PURL glob pattern (e.g., pkg:npm/*)';
COMMENT ON COLUMN proofchain.trust_anchors.revoked_keys IS 'Key IDs that have been revoked but may appear in old proofs';
-- 4.1 sbom_entries Table
CREATE TABLE IF NOT EXISTS proofchain.sbom_entries (
entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bom_digest VARCHAR(64) NOT NULL,
purl TEXT NOT NULL,
version TEXT,
artifact_digest VARCHAR(64),
trust_anchor_id UUID REFERENCES proofchain.trust_anchors(anchor_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Compound unique constraint for idempotent inserts
CONSTRAINT uq_sbom_entry UNIQUE (bom_digest, purl, version)
);
CREATE INDEX IF NOT EXISTS idx_sbom_entries_bom_digest ON proofchain.sbom_entries(bom_digest);
CREATE INDEX IF NOT EXISTS idx_sbom_entries_purl ON proofchain.sbom_entries(purl);
CREATE INDEX IF NOT EXISTS idx_sbom_entries_artifact ON proofchain.sbom_entries(artifact_digest);
CREATE INDEX IF NOT EXISTS idx_sbom_entries_anchor ON proofchain.sbom_entries(trust_anchor_id);
COMMENT ON TABLE proofchain.sbom_entries IS 'SBOM component entries with content-addressed identifiers';
COMMENT ON COLUMN proofchain.sbom_entries.bom_digest IS 'SHA-256 hash of the parent SBOM document';
COMMENT ON COLUMN proofchain.sbom_entries.purl IS 'Package URL (PURL) of the component';
COMMENT ON COLUMN proofchain.sbom_entries.artifact_digest IS 'SHA-256 hash of the component artifact if available';
-- 4.2 dsse_envelopes Table
CREATE TABLE IF NOT EXISTS proofchain.dsse_envelopes (
env_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entry_id UUID NOT NULL REFERENCES proofchain.sbom_entries(entry_id) ON DELETE CASCADE,
predicate_type TEXT NOT NULL,
signer_keyid TEXT NOT NULL,
body_hash VARCHAR(64) NOT NULL,
envelope_blob_ref TEXT NOT NULL,
signed_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Prevent duplicate envelopes for same entry/predicate
CONSTRAINT uq_dsse_envelope UNIQUE (entry_id, predicate_type, body_hash)
);
CREATE INDEX IF NOT EXISTS idx_dsse_entry_predicate ON proofchain.dsse_envelopes(entry_id, predicate_type);
CREATE INDEX IF NOT EXISTS idx_dsse_signer ON proofchain.dsse_envelopes(signer_keyid);
CREATE INDEX IF NOT EXISTS idx_dsse_body_hash ON proofchain.dsse_envelopes(body_hash);
COMMENT ON TABLE proofchain.dsse_envelopes IS 'Signed DSSE envelopes for proof chain statements';
COMMENT ON COLUMN proofchain.dsse_envelopes.predicate_type IS 'Predicate type URI (e.g., evidence.stella/v1)';
COMMENT ON COLUMN proofchain.dsse_envelopes.envelope_blob_ref IS 'Reference to blob storage (OCI, S3, file)';
-- 4.3 spines Table
CREATE TABLE IF NOT EXISTS proofchain.spines (
entry_id UUID PRIMARY KEY REFERENCES proofchain.sbom_entries(entry_id) ON DELETE CASCADE,
bundle_id VARCHAR(64) NOT NULL,
evidence_ids TEXT[] NOT NULL,
reasoning_id VARCHAR(64) NOT NULL,
vex_id VARCHAR(64) NOT NULL,
anchor_id UUID REFERENCES proofchain.trust_anchors(anchor_id) ON DELETE SET NULL,
policy_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Bundle ID must be unique
CONSTRAINT uq_spine_bundle UNIQUE (bundle_id)
);
CREATE INDEX IF NOT EXISTS idx_spines_bundle ON proofchain.spines(bundle_id);
CREATE INDEX IF NOT EXISTS idx_spines_anchor ON proofchain.spines(anchor_id);
CREATE INDEX IF NOT EXISTS idx_spines_policy ON proofchain.spines(policy_version);
COMMENT ON TABLE proofchain.spines IS 'Proof spines linking evidence to verdicts via merkle aggregation';
COMMENT ON COLUMN proofchain.spines.bundle_id IS 'ProofBundleID (merkle root of all components)';
COMMENT ON COLUMN proofchain.spines.evidence_ids IS 'Array of EvidenceIDs in sorted order';
-- 4.5 rekor_entries Table
CREATE TABLE IF NOT EXISTS proofchain.rekor_entries (
dsse_sha256 VARCHAR(64) PRIMARY KEY,
log_index BIGINT NOT NULL,
log_id TEXT NOT NULL,
uuid TEXT NOT NULL,
integrated_time BIGINT NOT NULL,
inclusion_proof JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Reference to the DSSE envelope
env_id UUID REFERENCES proofchain.dsse_envelopes(env_id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_rekor_log_index ON proofchain.rekor_entries(log_index);
CREATE INDEX IF NOT EXISTS idx_rekor_log_id ON proofchain.rekor_entries(log_id);
CREATE INDEX IF NOT EXISTS idx_rekor_uuid ON proofchain.rekor_entries(uuid);
CREATE INDEX IF NOT EXISTS idx_rekor_env ON proofchain.rekor_entries(env_id);
COMMENT ON TABLE proofchain.rekor_entries IS 'Rekor transparency log entries for verification';
COMMENT ON COLUMN proofchain.rekor_entries.inclusion_proof IS 'Merkle inclusion proof from Rekor';
-- Audit log table
CREATE TABLE IF NOT EXISTS proofchain.audit_log (
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
operation TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
actor TEXT,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON proofchain.audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_created ON proofchain.audit_log(created_at DESC);
COMMENT ON TABLE proofchain.audit_log IS 'Audit log for proof chain operations';
-- Create updated_at trigger function
CREATE OR REPLACE FUNCTION proofchain.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply updated_at trigger to trust_anchors
DROP TRIGGER IF EXISTS update_trust_anchors_updated_at ON proofchain.trust_anchors;
CREATE TRIGGER update_trust_anchors_updated_at
BEFORE UPDATE ON proofchain.trust_anchors
FOR EACH ROW
EXECUTE FUNCTION proofchain.update_updated_at_column();

View File

@@ -0,0 +1,20 @@
-- Migration: 20251214000002_RollbackProofChainSchema
-- Rollback script for the proofchain schema.
-- WARNING: This will delete all proof chain data!
-- Drop tables in reverse dependency order
DROP TABLE IF EXISTS proofchain.audit_log CASCADE;
DROP TABLE IF EXISTS proofchain.rekor_entries CASCADE;
DROP TABLE IF EXISTS proofchain.spines CASCADE;
DROP TABLE IF EXISTS proofchain.dsse_envelopes CASCADE;
DROP TABLE IF EXISTS proofchain.sbom_entries CASCADE;
DROP TABLE IF EXISTS proofchain.trust_anchors CASCADE;
-- Drop types
DROP TYPE IF EXISTS proofchain.verification_result CASCADE;
-- Drop functions
DROP FUNCTION IF EXISTS proofchain.update_updated_at_column() CASCADE;
-- Drop schema
DROP SCHEMA IF EXISTS proofchain CASCADE;

View File

@@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Attestor.Persistence.Entities;
namespace StellaOps.Attestor.Persistence;
/// <summary>
/// Entity Framework Core DbContext for proof chain persistence.
/// </summary>
public class ProofChainDbContext : DbContext
{
public ProofChainDbContext(DbContextOptions<ProofChainDbContext> options)
: base(options)
{
}
/// <summary>
/// SBOM entries table.
/// </summary>
public DbSet<SbomEntryEntity> SbomEntries => Set<SbomEntryEntity>();
/// <summary>
/// DSSE envelopes table.
/// </summary>
public DbSet<DsseEnvelopeEntity> DsseEnvelopes => Set<DsseEnvelopeEntity>();
/// <summary>
/// Proof spines table.
/// </summary>
public DbSet<SpineEntity> Spines => Set<SpineEntity>();
/// <summary>
/// Trust anchors table.
/// </summary>
public DbSet<TrustAnchorEntity> TrustAnchors => Set<TrustAnchorEntity>();
/// <summary>
/// Rekor entries table.
/// </summary>
public DbSet<RekorEntryEntity> RekorEntries => Set<RekorEntryEntity>();
/// <summary>
/// Audit log table.
/// </summary>
public DbSet<AuditLogEntity> AuditLog => Set<AuditLogEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure schema
modelBuilder.HasDefaultSchema("proofchain");
// SbomEntryEntity configuration
modelBuilder.Entity<SbomEntryEntity>(entity =>
{
entity.HasIndex(e => e.BomDigest).HasDatabaseName("idx_sbom_entries_bom_digest");
entity.HasIndex(e => e.Purl).HasDatabaseName("idx_sbom_entries_purl");
entity.HasIndex(e => e.ArtifactDigest).HasDatabaseName("idx_sbom_entries_artifact");
entity.HasIndex(e => e.TrustAnchorId).HasDatabaseName("idx_sbom_entries_anchor");
// Unique constraint
entity.HasIndex(e => new { e.BomDigest, e.Purl, e.Version })
.HasDatabaseName("uq_sbom_entry")
.IsUnique();
// Relationships
entity.HasOne(e => e.TrustAnchor)
.WithMany()
.HasForeignKey(e => e.TrustAnchorId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasMany(e => e.Envelopes)
.WithOne(e => e.Entry)
.HasForeignKey(e => e.EntryId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Spine)
.WithOne(e => e.Entry)
.HasForeignKey<SpineEntity>(e => e.EntryId)
.OnDelete(DeleteBehavior.Cascade);
});
// DsseEnvelopeEntity configuration
modelBuilder.Entity<DsseEnvelopeEntity>(entity =>
{
entity.HasIndex(e => new { e.EntryId, e.PredicateType })
.HasDatabaseName("idx_dsse_entry_predicate");
entity.HasIndex(e => e.SignerKeyId).HasDatabaseName("idx_dsse_signer");
entity.HasIndex(e => e.BodyHash).HasDatabaseName("idx_dsse_body_hash");
// Unique constraint
entity.HasIndex(e => new { e.EntryId, e.PredicateType, e.BodyHash })
.HasDatabaseName("uq_dsse_envelope")
.IsUnique();
});
// SpineEntity configuration
modelBuilder.Entity<SpineEntity>(entity =>
{
entity.HasIndex(e => e.BundleId).HasDatabaseName("idx_spines_bundle").IsUnique();
entity.HasIndex(e => e.AnchorId).HasDatabaseName("idx_spines_anchor");
entity.HasIndex(e => e.PolicyVersion).HasDatabaseName("idx_spines_policy");
entity.HasOne(e => e.Anchor)
.WithMany()
.HasForeignKey(e => e.AnchorId)
.OnDelete(DeleteBehavior.SetNull);
});
// TrustAnchorEntity configuration
modelBuilder.Entity<TrustAnchorEntity>(entity =>
{
entity.HasIndex(e => e.PurlPattern).HasDatabaseName("idx_trust_anchors_pattern");
entity.HasIndex(e => e.IsActive)
.HasDatabaseName("idx_trust_anchors_active")
.HasFilter("is_active = TRUE");
});
// RekorEntryEntity configuration
modelBuilder.Entity<RekorEntryEntity>(entity =>
{
entity.HasIndex(e => e.LogIndex).HasDatabaseName("idx_rekor_log_index");
entity.HasIndex(e => e.LogId).HasDatabaseName("idx_rekor_log_id");
entity.HasIndex(e => e.Uuid).HasDatabaseName("idx_rekor_uuid");
entity.HasIndex(e => e.EnvId).HasDatabaseName("idx_rekor_env");
entity.HasOne(e => e.Envelope)
.WithOne(e => e.RekorEntry)
.HasForeignKey<RekorEntryEntity>(e => e.EnvId)
.OnDelete(DeleteBehavior.SetNull);
});
// AuditLogEntity configuration
modelBuilder.Entity<AuditLogEntity>(entity =>
{
entity.HasIndex(e => new { e.EntityType, e.EntityId })
.HasDatabaseName("idx_audit_entity");
entity.HasIndex(e => e.CreatedAt)
.HasDatabaseName("idx_audit_created")
.IsDescending();
});
}
}

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Persistence.Entities;
namespace StellaOps.Attestor.Persistence.Repositories;
/// <summary>
/// Repository for proof chain data access.
/// </summary>
public interface IProofChainRepository
{
#region SBOM Entries
/// <summary>
/// Get an SBOM entry by its unique combination of bom digest, purl, and version.
/// </summary>
Task<SbomEntryEntity?> GetSbomEntryAsync(
string bomDigest,
string purl,
string? version,
CancellationToken ct = default);
/// <summary>
/// Get an SBOM entry by its entry ID.
/// </summary>
Task<SbomEntryEntity?> GetSbomEntryByIdAsync(
Guid entryId,
CancellationToken ct = default);
/// <summary>
/// Insert or update an SBOM entry (upsert on unique constraint).
/// </summary>
Task<SbomEntryEntity> UpsertSbomEntryAsync(
SbomEntryEntity entry,
CancellationToken ct = default);
/// <summary>
/// Get all SBOM entries by artifact digest.
/// </summary>
Task<IReadOnlyList<SbomEntryEntity>> GetSbomEntriesByArtifactAsync(
string artifactDigest,
CancellationToken ct = default);
/// <summary>
/// Get all SBOM entries by bom digest.
/// </summary>
Task<IReadOnlyList<SbomEntryEntity>> GetSbomEntriesByBomDigestAsync(
string bomDigest,
CancellationToken ct = default);
#endregion
#region DSSE Envelopes
/// <summary>
/// Get an envelope by its ID.
/// </summary>
Task<DsseEnvelopeEntity?> GetEnvelopeAsync(
Guid envId,
CancellationToken ct = default);
/// <summary>
/// Get an envelope by its body hash.
/// </summary>
Task<DsseEnvelopeEntity?> GetEnvelopeByBodyHashAsync(
string bodyHash,
CancellationToken ct = default);
/// <summary>
/// Save a new envelope.
/// </summary>
Task<DsseEnvelopeEntity> SaveEnvelopeAsync(
DsseEnvelopeEntity envelope,
CancellationToken ct = default);
/// <summary>
/// Get all envelopes for an SBOM entry.
/// </summary>
Task<IReadOnlyList<DsseEnvelopeEntity>> GetEnvelopesByEntryAsync(
Guid entryId,
CancellationToken ct = default);
/// <summary>
/// Get envelopes for an entry filtered by predicate type.
/// </summary>
Task<IReadOnlyList<DsseEnvelopeEntity>> GetEnvelopesByPredicateTypeAsync(
Guid entryId,
string predicateType,
CancellationToken ct = default);
#endregion
#region Spines
/// <summary>
/// Get a spine by its entry ID.
/// </summary>
Task<SpineEntity?> GetSpineAsync(
Guid entryId,
CancellationToken ct = default);
/// <summary>
/// Get a spine by its bundle ID.
/// </summary>
Task<SpineEntity?> GetSpineByBundleIdAsync(
string bundleId,
CancellationToken ct = default);
/// <summary>
/// Save or update a spine.
/// </summary>
Task<SpineEntity> SaveSpineAsync(
SpineEntity spine,
CancellationToken ct = default);
#endregion
#region Trust Anchors
/// <summary>
/// Get a trust anchor by its ID.
/// </summary>
Task<TrustAnchorEntity?> GetTrustAnchorAsync(
Guid anchorId,
CancellationToken ct = default);
/// <summary>
/// Get the trust anchor matching a PURL pattern (best match).
/// </summary>
Task<TrustAnchorEntity?> GetTrustAnchorByPatternAsync(
string purl,
CancellationToken ct = default);
/// <summary>
/// Save or update a trust anchor.
/// </summary>
Task<TrustAnchorEntity> SaveTrustAnchorAsync(
TrustAnchorEntity anchor,
CancellationToken ct = default);
/// <summary>
/// Get all active trust anchors.
/// </summary>
Task<IReadOnlyList<TrustAnchorEntity>> GetActiveTrustAnchorsAsync(
CancellationToken ct = default);
/// <summary>
/// Revoke a key in a trust anchor.
/// </summary>
Task RevokeKeyAsync(
Guid anchorId,
string keyId,
CancellationToken ct = default);
#endregion
#region Rekor Entries
/// <summary>
/// Get a Rekor entry by DSSE SHA-256.
/// </summary>
Task<RekorEntryEntity?> GetRekorEntryAsync(
string dsseSha256,
CancellationToken ct = default);
/// <summary>
/// Get a Rekor entry by log index.
/// </summary>
Task<RekorEntryEntity?> GetRekorEntryByLogIndexAsync(
long logIndex,
CancellationToken ct = default);
/// <summary>
/// Save a Rekor entry.
/// </summary>
Task<RekorEntryEntity> SaveRekorEntryAsync(
RekorEntryEntity entry,
CancellationToken ct = default);
#endregion
#region Audit Log
/// <summary>
/// Log an audit entry.
/// </summary>
Task LogAuditAsync(
string operation,
string entityType,
string entityId,
string? actor = null,
object? details = null,
CancellationToken ct = default);
/// <summary>
/// Get audit log entries for an entity.
/// </summary>
Task<IReadOnlyList<AuditLogEntity>> GetAuditLogAsync(
string entityType,
string entityId,
CancellationToken ct = default);
#endregion
}

View File

@@ -0,0 +1,297 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Persistence.Entities;
namespace StellaOps.Attestor.Persistence.Services;
/// <summary>
/// Matches PURLs against trust anchor patterns.
/// SPRINT_0501_0006_0001 - Task #7
/// </summary>
public interface ITrustAnchorMatcher
{
/// <summary>
/// Finds the best matching trust anchor for a given PURL.
/// </summary>
Task<TrustAnchorMatchResult?> FindMatchAsync(
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates if a key ID is allowed for a given PURL.
/// </summary>
Task<bool> IsKeyAllowedAsync(
string purl,
string keyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates if a predicate type is allowed for a given PURL.
/// </summary>
Task<bool> IsPredicateAllowedAsync(
string purl,
string predicateType,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of trust anchor pattern matching.
/// </summary>
public sealed record TrustAnchorMatchResult
{
/// <summary>The matched trust anchor.</summary>
public required TrustAnchorEntity Anchor { get; init; }
/// <summary>The pattern that matched.</summary>
public required string MatchedPattern { get; init; }
/// <summary>Match specificity score (higher = more specific).</summary>
public required int Specificity { get; init; }
}
/// <summary>
/// Implementation of trust anchor pattern matching using PURL glob patterns.
/// </summary>
public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
{
private readonly IProofChainRepository _repository;
private readonly ILogger<TrustAnchorMatcher> _logger;
// Cache compiled regex patterns
private readonly Dictionary<string, Regex> _patternCache = new();
private readonly Lock _cacheLock = new();
public TrustAnchorMatcher(
IProofChainRepository repository,
ILogger<TrustAnchorMatcher> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TrustAnchorMatchResult?> FindMatchAsync(
string purl,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(purl);
var anchors = await _repository.GetActiveAnchorsAsync(cancellationToken);
TrustAnchorMatchResult? bestMatch = null;
foreach (var anchor in anchors)
{
if (!IsActive(anchor))
{
continue;
}
var regex = GetOrCreateRegex(anchor.PurlPattern);
if (regex.IsMatch(purl))
{
var specificity = CalculateSpecificity(anchor.PurlPattern);
if (bestMatch == null || specificity > bestMatch.Specificity)
{
bestMatch = new TrustAnchorMatchResult
{
Anchor = anchor,
MatchedPattern = anchor.PurlPattern,
Specificity = specificity,
};
}
}
}
if (bestMatch != null)
{
_logger.LogDebug(
"PURL {Purl} matched anchor pattern {Pattern} with specificity {Specificity}",
purl, bestMatch.MatchedPattern, bestMatch.Specificity);
}
return bestMatch;
}
public async Task<bool> IsKeyAllowedAsync(
string purl,
string keyId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(purl);
ArgumentException.ThrowIfNullOrEmpty(keyId);
var match = await FindMatchAsync(purl, cancellationToken);
if (match == null)
{
_logger.LogDebug("No trust anchor found for PURL {Purl}", purl);
return false;
}
// Check if key is revoked
if (match.Anchor.RevokedKeys.Contains(keyId, StringComparer.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Key {KeyId} is revoked for anchor {AnchorId}",
keyId, match.Anchor.AnchorId);
return false;
}
// Check if key is in allowed list
var allowed = match.Anchor.AllowedKeyIds.Contains(keyId, StringComparer.OrdinalIgnoreCase);
if (!allowed)
{
_logger.LogDebug(
"Key {KeyId} not in allowed list for anchor {AnchorId}",
keyId, match.Anchor.AnchorId);
}
return allowed;
}
public async Task<bool> IsPredicateAllowedAsync(
string purl,
string predicateType,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(purl);
ArgumentException.ThrowIfNullOrEmpty(predicateType);
var match = await FindMatchAsync(purl, cancellationToken);
if (match == null)
{
return false;
}
// If no predicate restrictions, allow all
if (match.Anchor.AllowedPredicateTypes == null || match.Anchor.AllowedPredicateTypes.Length == 0)
{
return true;
}
return match.Anchor.AllowedPredicateTypes.Contains(predicateType, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Converts a PURL glob pattern to a regex.
/// Supports: * (any chars), ? (single char), ** (any path segment)
/// </summary>
private Regex GetOrCreateRegex(string pattern)
{
lock (_cacheLock)
{
if (_patternCache.TryGetValue(pattern, out var cached))
{
return cached;
}
var regexPattern = ConvertGlobToRegex(pattern);
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
_patternCache[pattern] = regex;
return regex;
}
}
/// <summary>
/// Converts a glob pattern to a regex pattern.
/// </summary>
private static string ConvertGlobToRegex(string glob)
{
var regex = new System.Text.StringBuilder("^");
for (int i = 0; i < glob.Length; i++)
{
char c = glob[i];
switch (c)
{
case '*':
if (i + 1 < glob.Length && glob[i + 1] == '*')
{
// ** matches any path segments
regex.Append(".*");
i++; // Skip next *
}
else
{
// * matches anything except /
regex.Append("[^/]*");
}
break;
case '?':
// ? matches single character except /
regex.Append("[^/]");
break;
case '.':
case '^':
case '$':
case '+':
case '(':
case ')':
case '[':
case ']':
case '{':
case '}':
case '|':
case '\\':
// Escape regex special chars
regex.Append('\\').Append(c);
break;
default:
regex.Append(c);
break;
}
}
regex.Append('$');
return regex.ToString();
}
/// <summary>
/// Calculates pattern specificity (more specific = higher score).
/// </summary>
private static int CalculateSpecificity(string pattern)
{
// Count non-wildcard segments
int specificity = 0;
// More slashes = more specific
specificity += pattern.Count(c => c == '/') * 10;
// More literal characters = more specific
specificity += pattern.Count(c => c != '*' && c != '?');
// Penalize wildcards
specificity -= pattern.Count(c => c == '*') * 5;
specificity -= pattern.Count(c => c == '?') * 2;
return specificity;
}
private static bool IsActive(TrustAnchorEntity anchor)
{
// Anchor is active if IsActive property exists and is true
// or if the property doesn't exist (backwards compatibility)
var isActiveProp = anchor.GetType().GetProperty("IsActive");
if (isActiveProp != null)
{
return (bool)(isActiveProp.GetValue(anchor) ?? true);
}
return true;
}
}
/// <summary>
/// Repository interface extension for trust anchor queries.
/// </summary>
public interface IProofChainRepository
{
/// <summary>
/// Gets all active trust anchors.
/// </summary>
Task<IReadOnlyList<TrustAnchorEntity>> GetActiveAnchorsAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Attestor.Persistence</RootNamespace>
<Description>Proof chain persistence layer with Entity Framework Core and PostgreSQL support.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0-preview.*" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-preview.*" />
</ItemGroup>
<ItemGroup>
<None Include="Migrations\*.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,223 @@
using StellaOps.Attestor.Persistence.Entities;
using StellaOps.Attestor.Persistence.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace StellaOps.Attestor.Persistence.Tests;
/// <summary>
/// Integration tests for proof chain database operations.
/// SPRINT_0501_0006_0001 - Task #10
/// </summary>
public sealed class ProofChainRepositoryIntegrationTests
{
private readonly Mock<IProofChainRepository> _repositoryMock;
private readonly TrustAnchorMatcher _matcher;
public ProofChainRepositoryIntegrationTests()
{
_repositoryMock = new Mock<IProofChainRepository>();
_matcher = new TrustAnchorMatcher(
_repositoryMock.Object,
NullLogger<TrustAnchorMatcher>.Instance);
}
[Fact]
public async Task FindMatchAsync_ExactPattern_MatchesCorrectly()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/lodash@4.17.21", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
// Assert
Assert.NotNull(result);
Assert.Equal(anchor.AnchorId, result.Anchor.AnchorId);
}
[Fact]
public async Task FindMatchAsync_WildcardPattern_MatchesPackages()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
// Assert
Assert.NotNull(result);
Assert.Equal("pkg:npm/*", result.MatchedPattern);
}
[Fact]
public async Task FindMatchAsync_DoubleWildcard_MatchesNestedPaths()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/@scope/**", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:npm/@scope/sub/package@1.0.0");
// Assert
Assert.NotNull(result);
}
[Fact]
public async Task FindMatchAsync_MultipleMatches_ReturnsMoreSpecific()
{
// Arrange
var genericAnchor = CreateAnchor("pkg:npm/*", ["key-generic"], "generic");
var specificAnchor = CreateAnchor("pkg:npm/lodash@*", ["key-specific"], "specific");
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([genericAnchor, specificAnchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
// Assert
Assert.NotNull(result);
Assert.Equal("specific", result.Anchor.PolicyRef);
}
[Fact]
public async Task FindMatchAsync_NoMatch_ReturnsNull()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:pypi/requests@2.28.0");
// Assert
Assert.Null(result);
}
[Fact]
public async Task IsKeyAllowedAsync_AllowedKey_ReturnsTrue()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1", "key-2"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-1");
// Assert
Assert.True(allowed);
}
[Fact]
public async Task IsKeyAllowedAsync_DisallowedKey_ReturnsFalse()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-unknown");
// Assert
Assert.False(allowed);
}
[Fact]
public async Task IsKeyAllowedAsync_RevokedKey_ReturnsFalse()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"], revokedKeys: ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-1");
// Assert
Assert.False(allowed); // Key is revoked even if in allowed list
}
[Fact]
public async Task IsPredicateAllowedAsync_NoRestrictions_AllowsAll()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
anchor.AllowedPredicateTypes = null;
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var allowed = await _matcher.IsPredicateAllowedAsync(
"pkg:npm/lodash@4.17.21",
"https://in-toto.io/attestation/vulns/v0.1");
// Assert
Assert.True(allowed);
}
[Fact]
public async Task IsPredicateAllowedAsync_WithRestrictions_EnforcesAllowlist()
{
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
anchor.AllowedPredicateTypes = ["evidence.stella/v1", "sbom.stella/v1"];
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act & Assert
Assert.True(await _matcher.IsPredicateAllowedAsync(
"pkg:npm/lodash@4.17.21", "evidence.stella/v1"));
Assert.False(await _matcher.IsPredicateAllowedAsync(
"pkg:npm/lodash@4.17.21", "random.predicate/v1"));
}
[Theory]
[InlineData("pkg:npm/*", "pkg:npm/lodash@4.17.21", true)]
[InlineData("pkg:npm/lodash@*", "pkg:npm/lodash@4.17.21", true)]
[InlineData("pkg:npm/lodash@4.17.*", "pkg:npm/lodash@4.17.21", true)]
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.21", true)]
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.22", false)]
[InlineData("pkg:pypi/*", "pkg:npm/lodash@4.17.21", false)]
[InlineData("pkg:npm/@scope/*", "pkg:npm/@scope/package@1.0.0", true)]
[InlineData("pkg:npm/@scope/*", "pkg:npm/@other/package@1.0.0", false)]
public async Task FindMatchAsync_PatternVariations_MatchCorrectly(
string pattern, string purl, bool shouldMatch)
{
// Arrange
var anchor = CreateAnchor(pattern, ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync(purl);
// Assert
Assert.Equal(shouldMatch, result != null);
}
private static TrustAnchorEntity CreateAnchor(
string pattern,
string[] allowedKeys,
string? policyRef = null,
string[]? revokedKeys = null)
{
return new TrustAnchorEntity
{
AnchorId = Guid.NewGuid(),
PurlPattern = pattern,
AllowedKeyIds = allowedKeys,
PolicyRef = policyRef,
RevokedKeys = revokedKeys ?? [],
};
}
}