1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! Cookie security attribute analysis.
//!
//! Fetches the target homepage and inspects every Set-Cookie header for:
//! - Missing Secure flag (cookie sent over HTTP)
//! - Missing HttpOnly flag (XSS can steal cookie)
//! - Missing / weak SameSite attribute (CSRF vector)
//! - Session cookies with excessively long Max-Age / Expires
//!
//! Only session-looking cookies are reported (contains "sess", "auth", "token",
//! "jwt", "id", "user" — ignoring analytics/tracking cookies).
use gossan_core::Target;
use reqwest::Client;
use secfinding::{Evidence, Finding, Severity};
pub async fn probe(client: &Client, target: &Target) -> anyhow::Result<Vec<Finding>> {
let Target::Web(asset) = target else {
return Ok(vec![]);
};
let base = asset.url.as_str();
let mut findings = Vec::new();
let resp = client.get(base).send().await?;
let headers = resp.headers().clone();
// Collect all Set-Cookie header values
let cookies: Vec<String> = headers
.get_all("set-cookie")
.iter()
.filter_map(|v| v.to_str().ok().map(|s| s.to_string()))
.collect();
for cookie_str in &cookies {
let lower = cookie_str.to_lowercase();
// Only flag cookies that look session-related
let name = cookie_str
.split('=')
.next()
.unwrap_or("")
.trim()
.to_lowercase();
let is_session_cookie = name.contains("sess")
|| name.contains("auth")
|| name.contains("token")
|| name.contains("jwt")
|| name.contains("sid")
|| name.contains("user")
|| name.contains("login")
|| name.contains("uid")
|| name.contains("access")
|| name.contains("refresh")
|| name.contains("remember");
if !is_session_cookie {
continue;
}
// Missing Secure flag
if !lower.contains("; secure") && !lower.starts_with("secure") {
crate::try_push_finding(
crate::misconfig_finding(
target,
Severity::Medium,
format!(
"Cookie '{}' missing Secure flag",
cookie_str.split('=').next().unwrap_or("?")
),
format!(
"Session cookie is transmitted over HTTP as well as HTTPS. \
Network-layer attackers (MITM, coffee shop) can steal the session. \
Cookie: {}",
&cookie_str.chars().take(100).collect::<String>()
),
)
.evidence(Evidence::HttpResponse {
status: resp.status().as_u16(),
headers: vec![(
"set-cookie".into(),
cookie_str.chars().take(120).collect::<String>().into(),
)],
body_excerpt: None,
})
.tag("cookie")
.tag("session")
.tag("web"),
&mut findings,
);
}
// Missing HttpOnly flag
if !lower.contains("httponly") {
crate::try_push_finding(
crate::misconfig_finding(
target,
Severity::Medium,
format!(
"Cookie '{}' missing HttpOnly flag",
cookie_str.split('=').next().unwrap_or("?")
),
format!(
"Session cookie is accessible via document.cookie — any XSS vulnerability \
can steal it. Add HttpOnly to prevent JS access. \
Cookie: {}",
&cookie_str.chars().take(100).collect::<String>()
),
)
.evidence(Evidence::HttpResponse {
status: resp.status().as_u16(),
headers: vec![(
"set-cookie".into(),
cookie_str.chars().take(120).collect::<String>().into(),
)],
body_excerpt: None,
})
.tag("cookie")
.tag("session")
.tag("web")
.tag("xss"),
&mut findings,
);
}
// Missing or weak SameSite
if !lower.contains("samesite") {
crate::try_push_finding(
crate::misconfig_finding(
target,
Severity::Low,
format!(
"Cookie '{}' missing SameSite attribute",
cookie_str.split('=').next().unwrap_or("?")
),
format!(
"No SameSite attribute — cookie is sent on cross-origin requests, \
enabling classic CSRF attacks on state-changing endpoints. \
Use SameSite=Strict or SameSite=Lax. \
Cookie: {}",
&cookie_str.chars().take(100).collect::<String>()
),
)
.evidence(Evidence::HttpResponse {
status: resp.status().as_u16(),
headers: vec![(
"set-cookie".into(),
cookie_str.chars().take(120).collect::<String>().into(),
)],
body_excerpt: None,
})
.tag("cookie")
.tag("csrf")
.tag("web"),
&mut findings,
);
} else if lower.contains("samesite=none") && !lower.contains("; secure") {
// SameSite=None without Secure is rejected by browsers but worth flagging
crate::try_push_finding(
crate::misconfig_finding(
target,
Severity::Low,
format!(
"Cookie '{}' SameSite=None without Secure",
cookie_str.split('=').next().unwrap_or("?")
),
"SameSite=None requires the Secure flag or browsers will reject the cookie. \
This is a misconfiguration that can cause auth failures.",
)
.tag("cookie")
.tag("web"),
&mut findings,
);
}
}
Ok(findings)
}