mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 07:06:53 +00:00
3d0e6ce0da
* feat: add security governance policy contracts * fix: require signatures for release assets * docs: update policy verification counts --------- Co-authored-by: houseme <housemecn@gmail.com>
278 lines
8.2 KiB
Rust
278 lines
8.2 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum ArtifactSourceKind {
|
|
WorkspaceBuild,
|
|
ThirdPartyDownload,
|
|
GeneratedReleaseAsset,
|
|
}
|
|
|
|
impl ArtifactSourceKind {
|
|
pub const fn requires_digest(self) -> bool {
|
|
matches!(self, Self::ThirdPartyDownload | Self::GeneratedReleaseAsset)
|
|
}
|
|
|
|
pub const fn requires_provenance(self) -> bool {
|
|
matches!(self, Self::GeneratedReleaseAsset)
|
|
}
|
|
|
|
pub const fn requires_signature(self) -> bool {
|
|
matches!(self, Self::GeneratedReleaseAsset)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct ArtifactIntegrityPolicy {
|
|
artifact: &'static str,
|
|
source: ArtifactSourceKind,
|
|
digest_required: bool,
|
|
signature_required: bool,
|
|
provenance_required: bool,
|
|
}
|
|
|
|
impl ArtifactIntegrityPolicy {
|
|
pub const fn new(
|
|
artifact: &'static str,
|
|
source: ArtifactSourceKind,
|
|
digest_required: bool,
|
|
signature_required: bool,
|
|
provenance_required: bool,
|
|
) -> Self {
|
|
Self {
|
|
artifact,
|
|
source,
|
|
digest_required,
|
|
signature_required,
|
|
provenance_required,
|
|
}
|
|
}
|
|
|
|
pub const fn artifact(self) -> &'static str {
|
|
self.artifact
|
|
}
|
|
|
|
pub const fn source(self) -> ArtifactSourceKind {
|
|
self.source
|
|
}
|
|
|
|
pub const fn digest_required(self) -> bool {
|
|
self.digest_required
|
|
}
|
|
|
|
pub const fn signature_required(self) -> bool {
|
|
self.signature_required
|
|
}
|
|
|
|
pub const fn provenance_required(self) -> bool {
|
|
self.provenance_required
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error, PartialEq, Eq)]
|
|
pub enum SupplyChainPolicyError {
|
|
#[error("artifact integrity policy at index {index} has an empty artifact")]
|
|
EmptyArtifact { index: usize },
|
|
|
|
#[error("artifact integrity policy for {artifact} must require a digest")]
|
|
DigestRequired { artifact: &'static str },
|
|
|
|
#[error("artifact integrity policy for {artifact} must require provenance")]
|
|
ProvenanceRequired { artifact: &'static str },
|
|
|
|
#[error("artifact integrity policy for {artifact} must require a signature")]
|
|
SignatureRequired { artifact: &'static str },
|
|
|
|
#[error("duplicate artifact integrity policy for {artifact}")]
|
|
DuplicateArtifact { artifact: &'static str },
|
|
}
|
|
|
|
pub fn validate_artifact_integrity_policies(policies: &[ArtifactIntegrityPolicy]) -> Result<(), SupplyChainPolicyError> {
|
|
let mut artifacts = BTreeSet::new();
|
|
|
|
for (index, policy) in policies.iter().copied().enumerate() {
|
|
if policy.artifact.trim().is_empty() {
|
|
return Err(SupplyChainPolicyError::EmptyArtifact { index });
|
|
}
|
|
|
|
if policy.source.requires_digest() && !policy.digest_required {
|
|
return Err(SupplyChainPolicyError::DigestRequired {
|
|
artifact: policy.artifact,
|
|
});
|
|
}
|
|
|
|
if policy.source.requires_provenance() && !policy.provenance_required {
|
|
return Err(SupplyChainPolicyError::ProvenanceRequired {
|
|
artifact: policy.artifact,
|
|
});
|
|
}
|
|
|
|
if policy.source.requires_signature() && !policy.signature_required {
|
|
return Err(SupplyChainPolicyError::SignatureRequired {
|
|
artifact: policy.artifact,
|
|
});
|
|
}
|
|
|
|
if !artifacts.insert(policy.artifact) {
|
|
return Err(SupplyChainPolicyError::DuplicateArtifact {
|
|
artifact: policy.artifact,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn validates_artifact_integrity_policies() {
|
|
let policies = [
|
|
ArtifactIntegrityPolicy::new("rustfs-server", ArtifactSourceKind::WorkspaceBuild, false, true, false),
|
|
ArtifactIntegrityPolicy::new(
|
|
"rustfs-cli-windows-amd64.zip",
|
|
ArtifactSourceKind::GeneratedReleaseAsset,
|
|
true,
|
|
true,
|
|
true,
|
|
),
|
|
];
|
|
|
|
assert!(validate_artifact_integrity_policies(&policies).is_ok());
|
|
assert_eq!(policies[0].artifact(), "rustfs-server");
|
|
assert_eq!(policies[0].source(), ArtifactSourceKind::WorkspaceBuild);
|
|
assert!(policies[1].digest_required());
|
|
assert!(policies[1].signature_required());
|
|
assert!(policies[1].provenance_required());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_empty_artifacts() {
|
|
let policies = [ArtifactIntegrityPolicy::new(
|
|
" ",
|
|
ArtifactSourceKind::WorkspaceBuild,
|
|
false,
|
|
false,
|
|
false,
|
|
)];
|
|
|
|
let err = validate_artifact_integrity_policies(&policies).expect_err("empty artifact should fail validation");
|
|
|
|
assert_eq!(err, SupplyChainPolicyError::EmptyArtifact { index: 0 });
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_external_artifacts_without_digest() {
|
|
let policies = [ArtifactIntegrityPolicy::new(
|
|
"third-party-tool",
|
|
ArtifactSourceKind::ThirdPartyDownload,
|
|
false,
|
|
false,
|
|
false,
|
|
)];
|
|
|
|
let err = validate_artifact_integrity_policies(&policies).expect_err("third-party artifact should require digest");
|
|
|
|
assert_eq!(
|
|
err,
|
|
SupplyChainPolicyError::DigestRequired {
|
|
artifact: "third-party-tool"
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_generated_artifacts_without_digest() {
|
|
let policies = [ArtifactIntegrityPolicy::new(
|
|
"rustfs-cli-windows-amd64.zip",
|
|
ArtifactSourceKind::GeneratedReleaseAsset,
|
|
false,
|
|
true,
|
|
true,
|
|
)];
|
|
|
|
let err = validate_artifact_integrity_policies(&policies).expect_err("generated artifact should require digest");
|
|
|
|
assert_eq!(
|
|
err,
|
|
SupplyChainPolicyError::DigestRequired {
|
|
artifact: "rustfs-cli-windows-amd64.zip"
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_generated_artifacts_without_provenance() {
|
|
let policies = [ArtifactIntegrityPolicy::new(
|
|
"rustfs-cli-windows-amd64.zip",
|
|
ArtifactSourceKind::GeneratedReleaseAsset,
|
|
true,
|
|
true,
|
|
false,
|
|
)];
|
|
|
|
let err = validate_artifact_integrity_policies(&policies).expect_err("generated artifact should require provenance");
|
|
|
|
assert_eq!(
|
|
err,
|
|
SupplyChainPolicyError::ProvenanceRequired {
|
|
artifact: "rustfs-cli-windows-amd64.zip"
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_generated_artifacts_without_signature() {
|
|
let policies = [ArtifactIntegrityPolicy::new(
|
|
"rustfs-cli-windows-amd64.zip",
|
|
ArtifactSourceKind::GeneratedReleaseAsset,
|
|
true,
|
|
false,
|
|
true,
|
|
)];
|
|
|
|
let err = validate_artifact_integrity_policies(&policies).expect_err("generated artifact should require signature");
|
|
|
|
assert_eq!(
|
|
err,
|
|
SupplyChainPolicyError::SignatureRequired {
|
|
artifact: "rustfs-cli-windows-amd64.zip"
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_artifacts() {
|
|
let policies = [
|
|
ArtifactIntegrityPolicy::new("rustfs-server", ArtifactSourceKind::WorkspaceBuild, false, true, false),
|
|
ArtifactIntegrityPolicy::new("rustfs-server", ArtifactSourceKind::WorkspaceBuild, false, false, false),
|
|
];
|
|
|
|
let err = validate_artifact_integrity_policies(&policies).expect_err("duplicate artifact should fail validation");
|
|
|
|
assert_eq!(
|
|
err,
|
|
SupplyChainPolicyError::DuplicateArtifact {
|
|
artifact: "rustfs-server"
|
|
}
|
|
);
|
|
}
|
|
}
|