///|
pub(all) enum BoundaryMode {
Anywhere
Word
Line
} derive(Eq, Debug)
///|
pub(all) struct LiteralRule {
id : String
literal : String
case_sensitive : Bool
boundary : BoundaryMode
severity : Severity
message : String
} derive(Eq, Debug)
///|
pub fn LiteralRule::new(
id : String,
literal : String,
severity? : Severity = High,
case_sensitive? : Bool = true,
boundary? : BoundaryMode = Anywhere,
message? : String = "Custom sensitive literal",
) -> LiteralRule {
{ id, literal, case_sensitive, boundary, severity, message }
}
///|
fn char_equal(left : Char, right : Char, case_sensitive : Bool) -> Bool {
if case_sensitive {
left == right
} else {
let normalized_left = if left >= 'A' && left <= 'Z' {
(left.to_int() + 32).unsafe_to_char()
} else {
left
}
let normalized_right = if right >= 'A' && right <= 'Z' {
(right.to_int() + 32).unsafe_to_char()
} else {
right
}
normalized_left == normalized_right
}
}
///|
fn literal_at(
chars : Array[Char],
needle : Array[Char],
position : Int,
case_sensitive : Bool,
) -> Bool {
if position < 0 || position + needle.length() > chars.length() {
return false
}
for i = 0; i < needle.length(); i = i + 1 {
if !char_equal(chars[position + i], needle[i], case_sensitive) {
return false
}
}
true
}
///|
fn word_boundary_at(chars : Array[Char], position : Int) -> Bool {
position <= 0 ||
position >= chars.length() ||
is_word_boundary(chars[position])
}
///|
fn line_boundary_at(chars : Array[Char], position : Int) -> Bool {
position <= 0 ||
position >= chars.length() ||
chars[position] == '\n' ||
chars[position] == '\r'
}
///|
fn boundaries_match(
chars : Array[Char],
start : Int,
end : Int,
mode : BoundaryMode,
) -> Bool {
match mode {
Anywhere => true
Word => word_boundary_at(chars, start - 1) && word_boundary_at(chars, end)
Line => line_boundary_at(chars, start - 1) && line_boundary_at(chars, end)
}
}
///|
pub(all) struct LiteralScanner {
rules : Array[LiteralRule]
include_preview : Bool
} derive(Eq, Debug)
///|
pub fn LiteralScanner::new(
rules : Array[LiteralRule],
include_preview? : Bool = false,
) -> LiteralScanner {
{ rules, include_preview }
}
///|
fn scan_literal_rule(
chars : Array[Char],
rule : LiteralRule,
include_preview : Bool,
) -> Array[Finding] {
let out : Array[Finding] = []
let needle = rule.literal.to_array()
if needle.length() == 0 {
return out
}
let mut position = 0
while position + needle.length() <= chars.length() {
if literal_at(chars, needle, position, rule.case_sensitive) {
let end = position + needle.length()
if boundaries_match(chars, position, end, rule.boundary) {
out.push(
Finding::new(
rule.id,
Custom,
rule.severity,
position,
end,
100,
safe_preview(chars, position, end, include_preview),
rule.message,
),
)
}
position = end
} else {
position = position + 1
}
}
out
}
///|
pub fn LiteralScanner::find(
self : LiteralScanner,
text : String,
) -> Array[Finding] {
let chars = text.to_array()
let out : Array[Finding] = []
for rule in self.rules {
for finding in scan_literal_rule(chars, rule, self.include_preview) {
out.push(finding)
}
}
sort_findings(out)
}
///|
pub fn LiteralScanner::redact(
self : LiteralScanner,
text : String,
policy? : Policy = Policy::secure_default(),
) -> String {
redact(text, self.find(text), policy~)
}
///|
pub(all) struct AllowRule {
detector_id : String?
exact_value : String?
line_contains : String?
reason : String
} derive(Eq, Debug)
///|
pub fn AllowRule::new(
detector_id? : String,
exact_value? : String,
line_contains? : String,
reason? : String = "Explicitly allowed",
) -> AllowRule {
{ detector_id, exact_value, line_contains, reason }
}
///|
fn line_span_at(chars : Array[Char], position : Int) -> Span {
let mut start = if position < 0 { 0 } else { position }
let mut end = start
while start > 0 && chars[start - 1] != '\n' && chars[start - 1] != '\r' {
start = start - 1
}
while end < chars.length() && chars[end] != '\n' && chars[end] != '\r' {
end = end + 1
}
{ start, end }
}
///|
fn option_matches(expected : String?, actual : String) -> Bool {
match expected {
None => true
Some(value) => value == actual
}
}
///|
fn allow_rule_matches(
chars : Array[Char],
finding : Finding,
rule : AllowRule,
) -> Bool {
if !option_matches(rule.detector_id, finding.detector_id) {
return false
}
match rule.exact_value {
Some(value) =>
if slice_chars(chars, finding.span.start, finding.span.end) != value {
return false
}
None => ()
}
match rule.line_contains {
Some(fragment) => {
let line = line_span_at(chars, finding.span.start)
if !slice_chars(chars, line.start, line.end).contains(fragment) {
return false
}
}
None => ()
}
true
}
///|
pub fn apply_allowlist(
text : String,
findings : Array[Finding],
rules : Array[AllowRule],
) -> Array[Finding] {
let chars = text.to_array()
findings.filter(fn(finding) {
!rules.any(fn(rule) { allow_rule_matches(chars, finding, rule) })
})
}
///|
pub(all) struct SourceLocation {
offset : Int
line : Int
column : Int
} derive(Eq, Debug)
///|
pub fn locate(text : String, offset : Int) -> SourceLocation {
let chars = text.to_array()
let target = if offset < 0 {
0
} else if offset > chars.length() {
chars.length()
} else {
offset
}
let mut line = 1
let mut column = 1
for i = 0; i < target; i = i + 1 {
if chars[i] == '\n' {
line = line + 1
column = 1
} else {
column = column + 1
}
}
{ offset: target, line, column }
}
///|
pub(all) struct LocatedFinding {
finding : Finding
start : SourceLocation
end : SourceLocation
} derive(Eq, Debug)
///|
pub fn locate_findings(
text : String,
findings : Array[Finding],
) -> Array[LocatedFinding] {
findings.map(fn(finding) {
{
finding,
start: locate(text, finding.span.start),
end: locate(text, finding.span.end),
}
})
}
///|
pub fn located_findings_to_text(items : Array[LocatedFinding]) -> String {
let out = StringBuilder::new()
for item in items {
out.write_string(item.finding.severity.name())
out.write_string(" ")
out.write_string(item.finding.detector_id)
out.write_string(" at ")
out.write_string(item.start.line.to_string())
out.write_char(':')
out.write_string(item.start.column.to_string())
out.write_string("-")
out.write_string(item.end.line.to_string())
out.write_char(':')
out.write_string(item.end.column.to_string())
out.write_string(" ")
out.write_string(item.finding.message)
out.write_char('\n')
}
out.to_string()
}