3 Commits

Author SHA1 Message Date
c0e8f8d813 release: bump version to v0.2.35 2026-03-20 22:54:30 +00:00
1c342c2a19 feat: add anonymous read mode (NORA_AUTH_ANONYMOUS_READ)
When auth is enabled with anonymous_read=true, GET/HEAD requests
are allowed without credentials (pull/download), while write
operations (PUT/POST/DELETE/PATCH) still require authentication.

Use case: public demo registries, read-only mirrors.

Config: NORA_AUTH_ANONYMOUS_READ=true or auth.anonymous_read=true
2026-03-20 22:48:41 +00:00
12d4a28d34 fix: address code review findings
- Pin slsa-github-generator and codeql-action by SHA (not tag)
- Replace anonymous tuple with GroupedActivity struct for readability
- Replace unwrap() with if-let for safety
- Add warning message on attestation failure instead of silent || true
- Fix clippy: map_or -> is_some_and
2026-03-20 22:14:16 +00:00
8 changed files with 102 additions and 31 deletions

View File

@@ -144,7 +144,7 @@ jobs:
- name: Smoke test — verify alpine image starts and responds - name: Smoke test — verify alpine image starts and responds
run: | run: |
docker rm -f nora-smoke 2>/dev/null || true docker rm -f nora-smoke 2>/dev/null || echo "WARNING: attestation failed, continuing without provenance"
docker run --rm -d --name nora-smoke -p 5555:4000 -e NORA_HOST=0.0.0.0 \ docker run --rm -d --name nora-smoke -p 5555:4000 -e NORA_HOST=0.0.0.0 \
${{ env.NORA }}/${{ env.IMAGE_NAME }}:latest ${{ env.NORA }}/${{ env.IMAGE_NAME }}:latest
for i in $(seq 1 10); do for i in $(seq 1 10); do
@@ -226,7 +226,7 @@ jobs:
cat nora-linux-amd64.sha256 cat nora-linux-amd64.sha256
- name: Generate SLSA provenance - name: Generate SLSA provenance
uses: slsa-framework/slsa-github-generator/.github/actions/generate-builder@v2.1.0 uses: slsa-framework/slsa-github-generator/.github/actions/generate-builder@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0
id: provenance-generate id: provenance-generate
continue-on-error: true continue-on-error: true
@@ -234,7 +234,7 @@ jobs:
if: always() if: always()
run: | run: |
# Generate provenance using gh attestation (built-in GitHub feature) # Generate provenance using gh attestation (built-in GitHub feature)
gh attestation create ./nora-linux-amd64 --repo ${{ github.repository }} --signer-workflow ${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml 2>/dev/null || true gh attestation create ./nora-linux-amd64 --repo ${{ github.repository }} --signer-workflow ${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml 2>/dev/null || echo "WARNING: attestation failed, continuing without provenance"
# Also create a simple provenance file for scorecard # Also create a simple provenance file for scorecard
cat > nora-v${{ github.ref_name }}.provenance.json << 'PROVEOF' cat > nora-v${{ github.ref_name }}.provenance.json << 'PROVEOF'
{ {

View File

@@ -32,7 +32,7 @@ jobs:
repo_token: ${{ secrets.SCORECARD_TOKEN || secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.SCORECARD_TOKEN || secrets.GITHUB_TOKEN }}
- name: Upload Scorecard results to GitHub Security tab - name: Upload Scorecard results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4 # tag required by scorecard webapp verification uses: github/codeql-action/upload-sarif@256d634097be96e792d6764f9edaefc4320557b1 # v4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
category: scorecard category: scorecard

View File

@@ -1,5 +1,16 @@
# Changelog # Changelog
## [0.2.35] - 2026-03-20
### Added
- **Anonymous read mode** (`NORA_AUTH_ANONYMOUS_READ=true`): allow pull/download without credentials while requiring auth for push. Use case: public demo registries, read-only mirrors.
### Fixed
- Pin slsa-github-generator and codeql-action by SHA instead of tag
- Replace anonymous tuple with named struct in activity grouping (readability)
- Replace unwrap() with if-let pattern in activity grouping (safety)
- Add warning message on SLSA attestation failure instead of silent suppression
## [0.2.34] - 2026-03-20 ## [0.2.34] - 2026-03-20
### Fixed ### Fixed

2
Cargo.lock generated
View File

@@ -1277,7 +1277,7 @@ dependencies = [
[[package]] [[package]]
name = "nora-registry" name = "nora-registry"
version = "0.2.34" version = "0.2.35"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",

View File

@@ -6,7 +6,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.2.34" version = "0.2.35"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["DevITWay <devitway@gmail.com>"] authors = ["DevITWay <devitway@gmail.com>"]

View File

@@ -94,6 +94,16 @@ pub async fn auth_middleware(
return next.run(request).await; return next.run(request).await;
} }
// Allow anonymous read if configured
let is_read_method = matches!(
*request.method(),
axum::http::Method::GET | axum::http::Method::HEAD
);
if state.config.auth.anonymous_read && is_read_method {
// Read requests allowed without auth
return next.run(request).await;
}
// Extract Authorization header // Extract Authorization header
let auth_header = request let auth_header = request
.headers() .headers()

View File

@@ -200,6 +200,9 @@ fn default_max_file_size() -> u64 {
pub struct AuthConfig { pub struct AuthConfig {
#[serde(default)] #[serde(default)]
pub enabled: bool, pub enabled: bool,
/// Allow anonymous read access (pull/download without auth, push requires auth)
#[serde(default)]
pub anonymous_read: bool,
#[serde(default = "default_htpasswd_file")] #[serde(default = "default_htpasswd_file")]
pub htpasswd_file: String, pub htpasswd_file: String,
#[serde(default = "default_token_storage")] #[serde(default = "default_token_storage")]
@@ -279,6 +282,7 @@ impl Default for AuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
anonymous_read: false,
htpasswd_file: "users.htpasswd".to_string(), htpasswd_file: "users.htpasswd".to_string(),
token_storage: "data/tokens".to_string(), token_storage: "data/tokens".to_string(),
} }
@@ -457,6 +461,9 @@ impl Config {
if let Ok(val) = env::var("NORA_AUTH_ENABLED") { if let Ok(val) = env::var("NORA_AUTH_ENABLED") {
self.auth.enabled = val.to_lowercase() == "true" || val == "1"; self.auth.enabled = val.to_lowercase() == "true" || val == "1";
} }
if let Ok(val) = env::var("NORA_AUTH_ANONYMOUS_READ") {
self.auth.anonymous_read = val.to_lowercase() == "true" || val == "1";
}
if let Ok(val) = env::var("NORA_AUTH_HTPASSWD_FILE") { if let Ok(val) = env::var("NORA_AUTH_HTPASSWD_FILE") {
self.auth.htpasswd_file = val; self.auth.htpasswd_file = val;
} }
@@ -741,10 +748,40 @@ mod tests {
fn test_auth_config_default() { fn test_auth_config_default() {
let a = AuthConfig::default(); let a = AuthConfig::default();
assert!(!a.enabled); assert!(!a.enabled);
assert!(!a.anonymous_read);
assert_eq!(a.htpasswd_file, "users.htpasswd"); assert_eq!(a.htpasswd_file, "users.htpasswd");
assert_eq!(a.token_storage, "data/tokens"); assert_eq!(a.token_storage, "data/tokens");
} }
#[test]
fn test_auth_anonymous_read_from_toml() {
let toml = r#"
[server]
host = "127.0.0.1"
port = 4000
[storage]
mode = "local"
[auth]
enabled = true
anonymous_read = true
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.auth.enabled);
assert!(config.auth.anonymous_read);
}
#[test]
fn test_env_override_anonymous_read() {
let mut config = Config::default();
std::env::set_var("NORA_AUTH_ANONYMOUS_READ", "true");
config.apply_env_overrides();
assert!(config.auth.anonymous_read);
std::env::remove_var("NORA_AUTH_ANONYMOUS_READ");
}
#[test] #[test]
fn test_maven_proxy_entry_simple() { fn test_maven_proxy_entry_simple() {
let entry = MavenProxyEntry::Simple("https://repo.example.com".to_string()); let entry = MavenProxyEntry::Simple("https://repo.example.com".to_string());

View File

@@ -75,43 +75,56 @@ pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
) )
} else { } else {
// Group consecutive identical entries (same action+artifact+registry+source) // Group consecutive identical entries (same action+artifact+registry+source)
let mut grouped: Vec<(String, String, String, String, String, usize)> = Vec::new(); struct GroupedActivity {
time: String,
action: String,
artifact: String,
registry: String,
source: String,
count: usize,
}
let mut grouped: Vec<GroupedActivity> = Vec::new();
for entry in &data.activity { for entry in &data.activity {
let action = entry.action.to_string(); let action = entry.action.to_string();
let last_match = grouped let is_repeat = grouped.last().is_some_and(|last| {
.last() last.action == action
.map(|(_, a, art, reg, src, _)| { && last.artifact == entry.artifact
*a == action && last.registry == entry.registry
&& *art == entry.artifact && last.source == entry.source
&& *reg == entry.registry });
&& *src == entry.source
})
.unwrap_or(false);
if last_match { if is_repeat {
grouped.last_mut().unwrap().5 += 1; if let Some(last) = grouped.last_mut() {
last.count += 1;
}
} else { } else {
let time_ago = format_relative_time(&entry.timestamp); grouped.push(GroupedActivity {
grouped.push(( time: format_relative_time(&entry.timestamp),
time_ago,
action, action,
entry.artifact.clone(), artifact: entry.artifact.clone(),
entry.registry.clone(), registry: entry.registry.clone(),
entry.source.clone(), source: entry.source.clone(),
1, count: 1,
)); });
} }
} }
grouped grouped
.iter() .iter()
.map(|(time, action, artifact, registry, source, count)| { .map(|g| {
let display_artifact = if *count > 1 { let display_artifact = if g.count > 1 {
format!("{} (x{})", artifact, count) format!("{} (x{})", g.artifact, g.count)
} else { } else {
artifact.clone() g.artifact.clone()
}; };
render_activity_row(time, action, &display_artifact, registry, source) render_activity_row(
&g.time,
&g.action,
&display_artifact,
&g.registry,
&g.source,
)
}) })
.collect() .collect()
}; };