// Lightweight advisory audit for security.txt documents.
// Findings are advisory, not validation failures: a document without `Policy` or `Canonical` may be
// perfectly legal RFC 9116. Run the validator first for hard constraints; the audit layer then
// describes risks and suggestions.
///|
/// The kinds of advisory findings the audit layer can produce.
pub(all) enum AuditFindingKind {
Expired
ExpiresSoon
NoPolicy
NoCanonical
CanonicalMismatch
InsecureWebUri
DuplicateSingletonField
UnknownExtension
NoPreferredLanguages
InvalidContext
Unsigned
} derive(Eq, Debug)
///|
/// Severity of a finding. `Warning` flags actively risky states.
pub(all) enum AuditSeverity {
Info
Warning
} derive(Eq, Debug)
///|
/// One advisory finding with an optional source line (0 when n/a).
pub struct AuditFinding {
kind : AuditFindingKind
severity : AuditSeverity
message : String
line : Int
} derive(Eq, Debug)
///|
/// Seconds before expiry that count as `ExpiresSoon` (project-defined).
pub const EXPIRES_SOON_SECONDS : Int64 = 30L * 24L * 60L * 60L
///|
/// Construct a finding.
pub fn audit_finding(
kind : AuditFindingKind,
severity : AuditSeverity,
message : String,
line : Int,
) -> AuditFinding {
{ kind, severity, message, line }
}
///|
/// Finding kind.
pub fn AuditFinding::kind(self : AuditFinding) -> AuditFindingKind {
self.kind
}
///|
/// Finding severity.
pub fn AuditFinding::severity(self : AuditFinding) -> AuditSeverity {
self.severity
}
///|
/// Human-readable description.
pub fn AuditFinding::message(self : AuditFinding) -> String {
self.message
}
///|
/// Source line (0 when the finding is document-wide).
pub fn AuditFinding::line(self : AuditFinding) -> Int {
self.line
}
///|
/// Stable kebab-case label, useful for CLI output.
pub fn AuditFindingKind::label(self : AuditFindingKind) -> String {
match self {
Expired => "expired"
ExpiresSoon => "expires-soon"
NoPolicy => "no-policy"
NoCanonical => "no-canonical"
CanonicalMismatch => "canonical-mismatch"
InsecureWebUri => "insecure-web-uri"
DuplicateSingletonField => "duplicate-singleton-field"
UnknownExtension => "unknown-extension"
NoPreferredLanguages => "no-preferred-languages"
InvalidContext => "invalid-context"
Unsigned => "unsigned"
}
}
///|
/// Stable severity label.
pub fn AuditSeverity::label(self : AuditSeverity) -> String {
match self {
Info => "info"
Warning => "warning"
}
}
///|
/// Audit a parsed document against `now` and a retrieval context; findings are deterministic.
pub fn audit(
document : SecurityTxt,
now : DateTime,
context : SecurityTxtContext,
) -> Array[AuditFinding] {
let findings : Array[AuditFinding] = []
fn push(
kind : AuditFindingKind,
severity : AuditSeverity,
message : String,
line : Int,
) {
findings.push(audit_finding(kind, severity, message, line))
}
match document.expires() {
Ok(expires) => {
let remaining = time_until_expiry(expires, now)
if remaining < 0L {
push(
Expired,
Warning,
"document has expired at \{expires.format_rfc3339()}",
0,
)
} else if remaining <= EXPIRES_SOON_SECONDS {
push(
ExpiresSoon,
Warning,
"document expires within 30 days (\{remaining} seconds remaining)",
0,
)
}
}
_ => ()
}
match document.policy() {
None =>
push(
NoPolicy,
Info,
"no Policy field: RFC 9116 recommends linking a vulnerability disclosure policy",
0,
)
_ => ()
}
match document.canonical() {
None =>
push(
NoCanonical,
Info,
"no Canonical field: a canonical URI helps detect tampering or forks",
0,
)
_ => ()
}
match document.preferred_languages_value() {
None =>
push(
NoPreferredLanguages,
Info,
"no Preferred-Languages field: content language is unspecified",
0,
)
_ => ()
}
if !document.canonicals().is_empty() {
match validate_retrieval_context(document, context) {
Ok(_) => ()
Err(err) =>
push(
CanonicalMismatch,
if context.retrieval_uri is Some(_) {
Warning
} else {
Info
},
err.message(),
0,
)
}
}
match validate_context(context) {
Ok(_) => ()
Err(err) =>
push(
InvalidContext,
Warning,
"retrieval context is not RFC 9116 compliant: \{err.message()}",
0,
)
}
let seen_extensions : Array[String] = []
let singleton_counts : Map[String, Int] = Map([])
for entry in document.entries() {
match entry.field {
Contact(value)
| Canonical(value)
| Encryption(value)
| Acknowledgments(value)
| Policy(value)
| Hiring(value) =>
match uri_scheme(value) {
Some("http") =>
push(
InsecureWebUri,
Warning,
"\{entry.field.name()} uses http://; prefer https://",
entry.line,
)
_ => ()
}
Extension(name, _) =>
if !seen_extensions.contains(name) {
seen_extensions.push(name)
push(
UnknownExtension,
Info,
"unknown extension field '\{name}' (RFC 9116 allows extensions; consumers may ignore it)",
entry.line,
)
}
Expires(_) | PreferredLanguages(_) => ()
}
if entry.field.is_singleton() {
let name = entry.field.name()
let count = match singleton_counts.get(name) {
Some(n) => n
None => 0
}
singleton_counts.set(name, count + 1)
}
}
for name, count in singleton_counts {
if count > 1 {
push(
DuplicateSingletonField,
Warning,
"singleton field '\{name}' appears \{count} times",
0,
)
}
}
match document.signature_state() {
Unsigned =>
push(
Unsigned,
Info,
"document is not digitally signed: authenticity cannot be checked by recipients",
0,
)
SignedUnverified => ()
}
findings
}