pr_01m47d15m3e54sn21z27rpy5n9/crates/scan/src/protection.rs

180 lines6,868 bytesCodeBlame
1//! Push protection: which lines a change adds, the secrets on them, and
2//! what git is told when a push is refused for them.
3
4use std::collections::HashSet;
5
6use similar::{ChangeTag, TextDiff};
7
8use crate::secrets::{self, ALLOW_MARKER, Hit, SecretKind};
9
10/// Files larger than this are not read for secrets.
11pub const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
12
13/// The lines of `new` (numbered from 1) that are not in `old`.
14pub fn added_lines(old: &str, new: &str) -> HashSet<u32> {
15 let diff = TextDiff::from_lines(old, new);
16 diff.iter_all_changes()
17 .filter(|change| change.tag() == ChangeTag::Insert)
18 .filter_map(|change| change.new_index().map(|index| index as u32 + 1))
19 .collect()
20}
21
22/// Text worth scanning, or `None` for binary, oversized or skipped files.
23pub fn text_of<'a>(path: &str, bytes: &'a [u8]) -> Option<&'a str> {
24 if secrets::skipped_path(path) || bytes.len() > MAX_FILE_BYTES || bytes.contains(&0) {
25 return None;
26 }
27 std::str::from_utf8(bytes).ok()
28}
29
30/// The secrets a change to one file adds: on its new lines only, so a
31/// secret already in the repository (found and decided on before) does
32/// not block every later push that touches the file.
33pub fn scan_change(path: &str, old: Option<&[u8]>, new: &[u8]) -> Vec<Hit> {
34 let Some(new) = text_of(path, new) else {
35 return Vec::new();
36 };
37 match old.and_then(|old| std::str::from_utf8(old).ok()) {
38 Some(old) => {
39 let added = added_lines(old, new);
40 secrets::scan_lines(new, |line| added.contains(&line))
41 }
42 None => secrets::scan_text(new),
43 }
44}
45
46/// A secret that stops a push.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct Blocked {
49 pub kind: SecretKind,
50 pub path: String,
51 pub line: u32,
52 /// The commit that adds it.
53 pub commit: String,
54 /// Where it can be allowed, once, by someone who may.
55 pub allow_url: Option<String>,
56}
57
58fn short(commit: &str) -> &str {
59 &commit[..commit.len().min(7)]
60}
61
62/// The reason git prints beside each refused ref: one line.
63pub fn reason(blocked: &[Blocked]) -> String {
64 match blocked {
65 [] => "refused".to_owned(),
66 [only] => format!("secret found: {}:{} has {}", only.path, only.line, only.kind.label()),
67 [first, rest @ ..] => format!(
68 "{} secrets found, first {}:{} ({})",
69 rest.len() + 1,
70 first.path,
71 first.line,
72 first.kind.label()
73 ),
74 }
75}
76
77/// What git shows the person pushing, a line at a time, as `remote:`
78/// lines: every secret, where it is, and both ways forward.
79pub fn explain(blocked: &[Blocked]) -> Vec<String> {
80 let count = blocked.len();
81 let mut lines = vec![
82 format!(
83 "g1t found {} in this push, so nothing was pushed.",
84 if count == 1 { "a secret".to_owned() } else { format!("{count} secrets") }
85 ),
86 String::new(),
87 ];
88 let width = blocked
89 .iter()
90 .map(|item| item.path.len() + item.line.to_string().len() + 1)
91 .max()
92 .unwrap_or(0);
93 for item in blocked {
94 let place = format!("{}:{}", item.path, item.line);
95 lines.push(format!(" {place:<width$} {} (commit {})", item.kind.label(), short(&item.commit)));
96 }
97 lines.extend([
98 String::new(),
99 "Take the secret out of the commit that adds it (git commit --amend, or".to_owned(),
100 "git rebase -i for an older commit), rotate it if it was ever real, and".to_owned(),
101 "push again.".to_owned(),
102 String::new(),
103 "If it is not a real secret, such as a test fixture:".to_owned(),
104 format!(" - add {ALLOW_MARKER} in a comment on its line, or"),
105 ]);
106 let links: Vec<&str> = blocked.iter().filter_map(|item| item.allow_url.as_deref()).collect();
107 match links.as_slice() {
108 [] => lines.push(" - ask a member of the workspace to allow it on the project's Security page.".to_owned()),
109 [one] => {
110 lines.push(format!(" - allow it once at {one}"));
111 }
112 many => {
113 lines.push(" - allow each once:".to_owned());
114 for link in many {
115 lines.push(format!(" {link}"));
116 }
117 }
118 }
119 lines.push(" Allowing is recorded with your name, then the same push goes through.".to_owned());
120 lines
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn key() -> String {
128 format!("AK{}", "IAZ7Q4N2XWLM3KDTRV")
129 }
130
131 #[test]
132 fn only_added_lines_count() {
133 let old = format!("a\n{}\nb\n", key());
134 let new = format!("a\n{}\nb\nc {}\n", key(), key());
135 assert_eq!(added_lines(&old, &new), HashSet::from([4]));
136 let hits = scan_change("src/app.ts", Some(old.as_bytes()), new.as_bytes());
137 assert_eq!(hits.len(), 1);
138 assert_eq!(hits[0].line, 4);
139 // A new file is scanned whole.
140 assert_eq!(scan_change("src/app.ts", None, new.as_bytes()).len(), 2);
141 // Binary files and lockfiles are not.
142 assert!(scan_change("bin/tool", None, &[0, 1, 2]).is_empty());
143 assert!(scan_change("package-lock.json", None, new.as_bytes()).is_empty());
144 }
145
146 #[test]
147 fn the_refusal_names_the_file_line_kind_and_ways_forward() {
148 let blocked = vec![Blocked {
149 kind: SecretKind::AwsAccessKey,
150 path: "config/prod.env".into(),
151 line: 3,
152 commit: "4807077b296e6edbf410d55e72749d3e1170c291".into(),
153 allow_url: Some("https://g1t.sh/acme/rocket/security?finding=sec_1".into()),
154 }];
155 assert_eq!(reason(&blocked), "secret found: config/prod.env:3 has an AWS access key");
156 let text = explain(&blocked).join("\n");
157 assert!(text.starts_with("g1t found a secret in this push, so nothing was pushed."));
158 assert!(text.contains("config/prod.env:3 an AWS access key (commit 4807077)"));
159 assert!(text.contains("g1t:allow-secret"));
160 assert!(text.contains("allow it once at https://g1t.sh/acme/rocket/security?finding=sec_1"));
161 assert!(text.contains("recorded with your name"));
162 }
163
164 #[test]
165 fn several_secrets_are_listed_and_counted() {
166 let item = |path: &str, line| Blocked {
167 kind: SecretKind::GithubToken,
168 path: path.into(),
169 line,
170 commit: "c71546fcd893".into(),
171 allow_url: None,
172 };
173 let blocked = vec![item("a.env", 1), item("src/deep/b.ts", 12)];
174 assert_eq!(reason(&blocked), "2 secrets found, first a.env:1 (a GitHub token)");
175 let text = explain(&blocked);
176 assert!(text[0].contains("2 secrets"));
177 assert!(text.iter().any(|line| line == " a.env:1 a GitHub token (commit c71546f)"));
178 assert!(text.iter().any(|line| line.contains("Security page")));
179 }
180}