failproofai 1.0.0-beta.2 → 1.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
//! Deterministic scrubbing of credentials before an event leaves the machine.
|
|
2
|
+
//!
|
|
3
|
+
//! # Determinism is the binding constraint
|
|
4
|
+
//!
|
|
5
|
+
//! The server dedups on a content hash, so the same input must always produce
|
|
6
|
+
//! the same bytes. That rules out anything sampled, time-dependent, or
|
|
7
|
+
//! model-driven — an LLM pass here would make a re-read of the same source
|
|
8
|
+
//! bytes hash differently and defeat dedup entirely. What is left is a fixed
|
|
9
|
+
//! pattern set, which is exactly what this is.
|
|
10
|
+
//!
|
|
11
|
+
//! # This is a floor, not a guarantee
|
|
12
|
+
//!
|
|
13
|
+
//! It catches the common accident: a key pasted into a terminal, a token in an
|
|
14
|
+
//! environment assignment, a bearer header in a captured request. It does not
|
|
15
|
+
//! catch a secret that looks like ordinary prose, and it is not a substitute
|
|
16
|
+
//! for not collecting sessions you would rather not send. The module says so
|
|
17
|
+
//! plainly because the config option is called `minimal` and users will
|
|
18
|
+
//! reasonably ask what it covers.
|
|
19
|
+
//!
|
|
20
|
+
//! # Redaction is visible
|
|
21
|
+
//!
|
|
22
|
+
//! Every match is replaced with a marker naming the pattern that fired, rather
|
|
23
|
+
//! than being silently dropped. A reader looking at a truncated command should
|
|
24
|
+
//! be able to tell that something was removed and why.
|
|
25
|
+
|
|
26
|
+
use serde_json::Value;
|
|
27
|
+
|
|
28
|
+
use crate::config::Redact;
|
|
29
|
+
|
|
30
|
+
/// Characters that can appear inside an opaque token.
|
|
31
|
+
fn is_token_char(c: char) -> bool {
|
|
32
|
+
c.is_ascii_alphanumeric() || c == '_' || c == '-'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// A token must not be preceded by another token character, or `sk-` would
|
|
36
|
+
/// match inside `risk-averse` and `ri` would be left dangling in the output.
|
|
37
|
+
fn at_boundary(bytes: &[u8], start: usize) -> bool {
|
|
38
|
+
if start == 0 {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
let prev = bytes[start - 1] as char;
|
|
42
|
+
!is_token_char(prev)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// A literal-prefixed opaque token: `<prefix><token chars>`.
|
|
46
|
+
struct PrefixRule {
|
|
47
|
+
prefix: &'static str,
|
|
48
|
+
/// Token characters required after the prefix before this counts as a
|
|
49
|
+
/// secret. Keeps ordinary hyphenated words from matching.
|
|
50
|
+
min_len: usize,
|
|
51
|
+
label: &'static str,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Order matters: longer, more specific prefixes are tried first so
|
|
55
|
+
/// `sk-ant-...` is labelled as an Anthropic key rather than a generic one.
|
|
56
|
+
const PREFIX_RULES: &[PrefixRule] = &[
|
|
57
|
+
PrefixRule {
|
|
58
|
+
prefix: "sk-ant-api",
|
|
59
|
+
min_len: 16,
|
|
60
|
+
label: "anthropic-key",
|
|
61
|
+
},
|
|
62
|
+
PrefixRule {
|
|
63
|
+
prefix: "sk-ant-",
|
|
64
|
+
min_len: 16,
|
|
65
|
+
label: "anthropic-key",
|
|
66
|
+
},
|
|
67
|
+
PrefixRule {
|
|
68
|
+
prefix: "sk-proj-",
|
|
69
|
+
min_len: 16,
|
|
70
|
+
label: "openai-key",
|
|
71
|
+
},
|
|
72
|
+
PrefixRule {
|
|
73
|
+
prefix: "sk-",
|
|
74
|
+
min_len: 16,
|
|
75
|
+
label: "api-key",
|
|
76
|
+
},
|
|
77
|
+
PrefixRule {
|
|
78
|
+
prefix: "ghp_",
|
|
79
|
+
min_len: 20,
|
|
80
|
+
label: "github-token",
|
|
81
|
+
},
|
|
82
|
+
PrefixRule {
|
|
83
|
+
prefix: "gho_",
|
|
84
|
+
min_len: 20,
|
|
85
|
+
label: "github-token",
|
|
86
|
+
},
|
|
87
|
+
PrefixRule {
|
|
88
|
+
prefix: "ghu_",
|
|
89
|
+
min_len: 20,
|
|
90
|
+
label: "github-token",
|
|
91
|
+
},
|
|
92
|
+
PrefixRule {
|
|
93
|
+
prefix: "ghs_",
|
|
94
|
+
min_len: 20,
|
|
95
|
+
label: "github-token",
|
|
96
|
+
},
|
|
97
|
+
PrefixRule {
|
|
98
|
+
prefix: "ghr_",
|
|
99
|
+
min_len: 20,
|
|
100
|
+
label: "github-token",
|
|
101
|
+
},
|
|
102
|
+
PrefixRule {
|
|
103
|
+
prefix: "github_pat_",
|
|
104
|
+
min_len: 20,
|
|
105
|
+
label: "github-token",
|
|
106
|
+
},
|
|
107
|
+
PrefixRule {
|
|
108
|
+
prefix: "sb_secret_",
|
|
109
|
+
min_len: 16,
|
|
110
|
+
label: "supabase-key",
|
|
111
|
+
},
|
|
112
|
+
PrefixRule {
|
|
113
|
+
prefix: "sbp_",
|
|
114
|
+
min_len: 20,
|
|
115
|
+
label: "supabase-key",
|
|
116
|
+
},
|
|
117
|
+
PrefixRule {
|
|
118
|
+
prefix: "xoxb-",
|
|
119
|
+
min_len: 16,
|
|
120
|
+
label: "slack-token",
|
|
121
|
+
},
|
|
122
|
+
PrefixRule {
|
|
123
|
+
prefix: "xoxp-",
|
|
124
|
+
min_len: 16,
|
|
125
|
+
label: "slack-token",
|
|
126
|
+
},
|
|
127
|
+
PrefixRule {
|
|
128
|
+
prefix: "AKIA",
|
|
129
|
+
min_len: 16,
|
|
130
|
+
label: "aws-access-key-id",
|
|
131
|
+
},
|
|
132
|
+
PrefixRule {
|
|
133
|
+
prefix: "ASIA",
|
|
134
|
+
min_len: 16,
|
|
135
|
+
label: "aws-access-key-id",
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
/// Assignment names whose value is treated as secret.
|
|
140
|
+
///
|
|
141
|
+
/// Matched case-insensitively as a suffix of the identifier, so `API_KEY`,
|
|
142
|
+
/// `OPENAI_API_KEY` and `--api-key` all hit `key`. Deliberately narrow:
|
|
143
|
+
/// widening it to anything like `NAME` or `ID` would redact most of a command
|
|
144
|
+
/// line and make the capture useless.
|
|
145
|
+
/// Names strong enough to redact on their own, even as a bare identifier.
|
|
146
|
+
const STRONG_SECRET_NAMES: &[&str] = &["secret", "password", "passwd", "credential"];
|
|
147
|
+
/// Names that are only convincing as part of a COMPOUND identifier.
|
|
148
|
+
///
|
|
149
|
+
/// `key` and `token` are ordinary words in source code — measured against 40
|
|
150
|
+
/// real transcripts, a bare `key=` matched React's `key` prop on every JSX
|
|
151
|
+
/// list. Requiring a `_` or `-` keeps `API_KEY`, `api_key` and `--api-token`
|
|
152
|
+
/// while dropping that entire class of false positive.
|
|
153
|
+
const WEAK_SECRET_NAMES: &[&str] = &["key", "token"];
|
|
154
|
+
|
|
155
|
+
/// Shortest assignment value worth redacting. Below this it is far more likely
|
|
156
|
+
/// to be a placeholder or a flag than a credential.
|
|
157
|
+
const MIN_ASSIGNMENT_VALUE: usize = 12;
|
|
158
|
+
|
|
159
|
+
/// Scrub every string leaf of an event in place.
|
|
160
|
+
///
|
|
161
|
+
/// Returns the number of replacements, so a caller can log that redaction
|
|
162
|
+
/// actually did something without logging what it removed.
|
|
163
|
+
pub fn scrub_value(v: &mut Value, mode: Redact) -> usize {
|
|
164
|
+
if mode == Redact::Off {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
let mut n = 0;
|
|
168
|
+
scrub_in_place(v, &mut n);
|
|
169
|
+
n
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn scrub_in_place(v: &mut Value, n: &mut usize) {
|
|
173
|
+
match v {
|
|
174
|
+
Value::String(s) => {
|
|
175
|
+
if let Some(replaced) = scrub_str(s) {
|
|
176
|
+
*n += replaced.1;
|
|
177
|
+
*s = replaced.0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
Value::Array(a) => a.iter_mut().for_each(|e| scrub_in_place(e, n)),
|
|
181
|
+
Value::Object(o) => o.values_mut().for_each(|e| scrub_in_place(e, n)),
|
|
182
|
+
_ => {}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Scrub one string. `None` when nothing matched, so an unchanged string is
|
|
187
|
+
/// never reallocated.
|
|
188
|
+
pub fn scrub_str(s: &str) -> Option<(String, usize)> {
|
|
189
|
+
let bytes = s.as_bytes();
|
|
190
|
+
let mut out = String::new();
|
|
191
|
+
let mut i = 0usize;
|
|
192
|
+
let mut hits = 0usize;
|
|
193
|
+
|
|
194
|
+
while i < s.len() {
|
|
195
|
+
if !s.is_char_boundary(i) {
|
|
196
|
+
i += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
let rest = &s[i..];
|
|
200
|
+
|
|
201
|
+
if let Some((len, label)) = match_prefix_rule(bytes, i, rest)
|
|
202
|
+
.or_else(|| match_jwt(bytes, i, rest))
|
|
203
|
+
.or_else(|| match_bearer(rest))
|
|
204
|
+
.or_else(|| match_assignment(s, i, rest))
|
|
205
|
+
{
|
|
206
|
+
if hits == 0 {
|
|
207
|
+
out.push_str(&s[..i]);
|
|
208
|
+
}
|
|
209
|
+
out.push_str(&format!("[redacted:{label}]"));
|
|
210
|
+
hits += 1;
|
|
211
|
+
i += len;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if hits > 0 {
|
|
216
|
+
out.push_str(&rest[..rest.chars().next().map(char::len_utf8).unwrap_or(1)]);
|
|
217
|
+
}
|
|
218
|
+
i += rest.chars().next().map(char::len_utf8).unwrap_or(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
(hits > 0).then_some((out, hits))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fn match_prefix_rule(bytes: &[u8], i: usize, rest: &str) -> Option<(usize, &'static str)> {
|
|
225
|
+
if !at_boundary(bytes, i) {
|
|
226
|
+
return None;
|
|
227
|
+
}
|
|
228
|
+
for rule in PREFIX_RULES {
|
|
229
|
+
let Some(after) = rest.strip_prefix(rule.prefix) else {
|
|
230
|
+
continue;
|
|
231
|
+
};
|
|
232
|
+
let token_len = after.chars().take_while(|c| is_token_char(*c)).count();
|
|
233
|
+
if token_len >= rule.min_len {
|
|
234
|
+
return Some((rule.prefix.len() + token_len, rule.label));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
None
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// A JWT: three dot-separated base64url segments starting `eyJ`.
|
|
241
|
+
///
|
|
242
|
+
/// Matched structurally rather than by prefix alone, because `eyJ` on its own
|
|
243
|
+
/// is just base64 for `{"` and appears in plenty of non-secret payloads.
|
|
244
|
+
fn match_jwt(bytes: &[u8], i: usize, rest: &str) -> Option<(usize, &'static str)> {
|
|
245
|
+
if !at_boundary(bytes, i) || !rest.starts_with("eyJ") {
|
|
246
|
+
return None;
|
|
247
|
+
}
|
|
248
|
+
let is_b64 = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=';
|
|
249
|
+
|
|
250
|
+
let mut len = 0usize;
|
|
251
|
+
let mut segments = 0usize;
|
|
252
|
+
let mut cursor = rest;
|
|
253
|
+
loop {
|
|
254
|
+
let seg = cursor.chars().take_while(|c| is_b64(*c)).count();
|
|
255
|
+
if seg == 0 {
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
len += seg;
|
|
259
|
+
segments += 1;
|
|
260
|
+
cursor = &cursor[seg..];
|
|
261
|
+
if segments == 3 {
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
if cursor.starts_with('.') {
|
|
265
|
+
len += 1;
|
|
266
|
+
cursor = &cursor[1..];
|
|
267
|
+
} else {
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Three segments and a plausible total length. A two-segment match is far
|
|
272
|
+
// more likely to be ordinary base64 than a token.
|
|
273
|
+
(segments == 3 && len >= 40).then_some((len, "jwt"))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// `Bearer <token>` from an Authorization header.
|
|
277
|
+
fn match_bearer(rest: &str) -> Option<(usize, &'static str)> {
|
|
278
|
+
let lower = rest.get(..7)?.to_ascii_lowercase();
|
|
279
|
+
if lower != "bearer " {
|
|
280
|
+
return None;
|
|
281
|
+
}
|
|
282
|
+
let after = &rest[7..];
|
|
283
|
+
let token_len = after
|
|
284
|
+
.chars()
|
|
285
|
+
.take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
|
|
286
|
+
.count();
|
|
287
|
+
(token_len >= 8).then_some((7 + token_len, "bearer-token"))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/// The VALUE of a `SOMETHING_KEY=` / `--api-token=` assignment.
|
|
291
|
+
///
|
|
292
|
+
/// Anchored at the start of the value rather than at the `=`, so the marker
|
|
293
|
+
/// replaces only the secret and the assignment still reads as one:
|
|
294
|
+
/// `API_KEY=[redacted:secret-assignment]`, not `API_KEY[redacted:...]`.
|
|
295
|
+
///
|
|
296
|
+
/// Looks BACKWARDS at the identifier, so it works for env assignments, CLI
|
|
297
|
+
/// flags and `key=value` pairs alike, and steps back over one opening quote so
|
|
298
|
+
/// the quoting survives intact.
|
|
299
|
+
fn match_assignment(s: &str, i: usize, rest: &str) -> Option<(usize, &'static str)> {
|
|
300
|
+
if i == 0 {
|
|
301
|
+
return None;
|
|
302
|
+
}
|
|
303
|
+
let before = &s[..i];
|
|
304
|
+
// Step back over an opening quote, if any, then require the `=`.
|
|
305
|
+
let before = match before.chars().next_back() {
|
|
306
|
+
Some('"') | Some('\'') => &before[..before.len() - 1],
|
|
307
|
+
_ => before,
|
|
308
|
+
};
|
|
309
|
+
if !before.ends_with('=') {
|
|
310
|
+
return None;
|
|
311
|
+
}
|
|
312
|
+
let name_part = &before[..before.len() - 1];
|
|
313
|
+
let name_len = name_part
|
|
314
|
+
.chars()
|
|
315
|
+
.rev()
|
|
316
|
+
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
|
317
|
+
.count();
|
|
318
|
+
if name_len == 0 {
|
|
319
|
+
return None;
|
|
320
|
+
}
|
|
321
|
+
let raw = name_part[name_part.len() - name_len..].to_ascii_lowercase();
|
|
322
|
+
let name = raw.trim_matches('-');
|
|
323
|
+
let compound = name.contains('_') || name.contains('-');
|
|
324
|
+
let convincing = STRONG_SECRET_NAMES.iter().any(|n| name.ends_with(n))
|
|
325
|
+
|| (compound && WEAK_SECRET_NAMES.iter().any(|n| name.ends_with(n)));
|
|
326
|
+
if !convincing {
|
|
327
|
+
return None;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// An expression reference is not a literal secret: `key={m.id}`,
|
|
331
|
+
// `Bearer ${API_KEY}`, `token=<placeholder>`. Redacting these adds no
|
|
332
|
+
// safety and makes captured source unreadable.
|
|
333
|
+
if rest.starts_with(['{', '$', '<', '(', '`']) {
|
|
334
|
+
return None;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The value runs to the closing quote, or to whitespace / a shell
|
|
338
|
+
// separator when unquoted. The closing quote is left in place.
|
|
339
|
+
let quoted = matches!(s[..i].chars().next_back(), Some('"') | Some('\''));
|
|
340
|
+
let value_len = rest
|
|
341
|
+
.chars()
|
|
342
|
+
.take_while(|c| {
|
|
343
|
+
if quoted {
|
|
344
|
+
*c != '"' && *c != '\''
|
|
345
|
+
} else {
|
|
346
|
+
!c.is_whitespace() && *c != ';' && *c != '&'
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
.count();
|
|
350
|
+
(value_len >= MIN_ASSIGNMENT_VALUE).then_some((value_len, "secret-assignment"))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#[cfg(test)]
|
|
354
|
+
mod tests {
|
|
355
|
+
use super::*;
|
|
356
|
+
use serde_json::json;
|
|
357
|
+
|
|
358
|
+
fn scrub(s: &str) -> String {
|
|
359
|
+
scrub_str(s)
|
|
360
|
+
.map(|(v, _)| v)
|
|
361
|
+
.unwrap_or_else(|| s.to_string())
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#[test]
|
|
365
|
+
fn redacts_the_key_shapes_that_actually_leak() {
|
|
366
|
+
// Labelled by the most specific rule that matches: the key's own
|
|
367
|
+
// prefix beats the generic assignment rule, which is more useful to
|
|
368
|
+
// whoever reads the redacted line.
|
|
369
|
+
assert_eq!(
|
|
370
|
+
scrub("export ANTHROPIC_API_KEY=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv"),
|
|
371
|
+
"export ANTHROPIC_API_KEY=[redacted:anthropic-key]"
|
|
372
|
+
);
|
|
373
|
+
// A value with no recognisable prefix still goes, via the assignment
|
|
374
|
+
// rule — this is the case the prefix list cannot cover.
|
|
375
|
+
assert_eq!(
|
|
376
|
+
scrub("DATABASE_PASSWORD=hunter2hunter2hunter2"),
|
|
377
|
+
"DATABASE_PASSWORD=[redacted:secret-assignment]"
|
|
378
|
+
);
|
|
379
|
+
assert!(
|
|
380
|
+
scrub("here is sk-proj-abcdefghijklmnopqrstuvwxyz").contains("[redacted:openai-key]")
|
|
381
|
+
);
|
|
382
|
+
assert!(
|
|
383
|
+
scrub("token ghp_abcdefghijklmnopqrstuvwxyz0123").contains("[redacted:github-token]")
|
|
384
|
+
);
|
|
385
|
+
assert!(scrub("AKIAIOSFODNN7EXAMPLE0000").contains("[redacted:aws-access-key-id]"));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#[test]
|
|
389
|
+
fn redacts_a_bearer_header() {
|
|
390
|
+
let out = scrub(r#"curl -H "Authorization: Bearer sk-pv7KDjLZ2u-uMgM2ym2uyw" https://x"#);
|
|
391
|
+
assert!(out.contains("[redacted:bearer-token]"), "got {out}");
|
|
392
|
+
assert!(!out.contains("pv7KDjLZ"), "the token survived: {out}");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#[test]
|
|
396
|
+
fn redacts_a_jwt_but_not_ordinary_base64() {
|
|
397
|
+
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sIgNaTuRe0123456789ab";
|
|
398
|
+
assert!(scrub(jwt).contains("[redacted:jwt]"));
|
|
399
|
+
// Two segments is far more likely to be ordinary base64 than a token.
|
|
400
|
+
assert_eq!(
|
|
401
|
+
scrub("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0"),
|
|
402
|
+
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0"
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#[test]
|
|
407
|
+
fn leaves_ordinary_text_alone() {
|
|
408
|
+
// The false-positive cases that would make a capture useless. `risk-`
|
|
409
|
+
// contains `sk-`, which is why prefixes are boundary-anchored.
|
|
410
|
+
for s in [
|
|
411
|
+
"this is a risk-averse approach",
|
|
412
|
+
"run the task-runner now",
|
|
413
|
+
"cargo build --release",
|
|
414
|
+
"AWS_REGION=us-east-1",
|
|
415
|
+
"let name = compute();",
|
|
416
|
+
"no secrets here at all",
|
|
417
|
+
] {
|
|
418
|
+
assert_eq!(scrub(s), s, "false positive on {s:?}");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#[test]
|
|
423
|
+
fn a_bare_key_prop_in_source_code_is_not_a_secret() {
|
|
424
|
+
// Measured against 40 real transcripts: a bare `key=` matched React's
|
|
425
|
+
// `key` prop on every JSX list, 169 times. Requiring a compound
|
|
426
|
+
// identifier keeps API_KEY and api_key while dropping that whole class.
|
|
427
|
+
assert_eq!(
|
|
428
|
+
scrub("<MatchCard key={m.identifier} match={m} />"),
|
|
429
|
+
"<MatchCard key={m.identifier} match={m} />"
|
|
430
|
+
);
|
|
431
|
+
assert_eq!(scrub("key=someLongIdentifier"), "key=someLongIdentifier");
|
|
432
|
+
// Compound names still redact.
|
|
433
|
+
assert!(scrub("API_KEY=abcdefghijklmnop").contains("[redacted:"));
|
|
434
|
+
assert!(scrub("--api-token=abcdefghijklmnop").contains("[redacted:"));
|
|
435
|
+
// Strong names redact even bare.
|
|
436
|
+
assert!(scrub("password=abcdefghijklmnop").contains("[redacted:"));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#[test]
|
|
440
|
+
fn an_expression_reference_is_not_redacted() {
|
|
441
|
+
// Redacting these adds no safety and makes captured source unreadable.
|
|
442
|
+
for s in [
|
|
443
|
+
"Authorization: `Bearer ${API_KEY}`",
|
|
444
|
+
"api_key=$OPENAI_API_KEY",
|
|
445
|
+
"--token=<your-token-here>",
|
|
446
|
+
] {
|
|
447
|
+
assert!(
|
|
448
|
+
!scrub(s).contains("secret-assignment"),
|
|
449
|
+
"over-redacted {s:?} -> {}",
|
|
450
|
+
scrub(s)
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#[test]
|
|
456
|
+
fn a_short_assignment_value_is_left_alone() {
|
|
457
|
+
// Placeholders and flags, not credentials.
|
|
458
|
+
assert_eq!(scrub("API_KEY=abc"), "API_KEY=abc");
|
|
459
|
+
assert_eq!(scrub("--token=x"), "--token=x");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
#[test]
|
|
463
|
+
fn redaction_is_deterministic() {
|
|
464
|
+
// Non-negotiable: the server dedups on a content hash, so the same
|
|
465
|
+
// input must always produce the same bytes.
|
|
466
|
+
let s = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv and ghp_abcdefghijklmnopqrstuvwxyz0123";
|
|
467
|
+
// Three independent runs over the same input, since a single
|
|
468
|
+
// comparison would also pass for a function that returned its input.
|
|
469
|
+
assert_eq!(scrub(s), scrub(s));
|
|
470
|
+
assert_eq!(scrub(s), scrub(s));
|
|
471
|
+
assert!(
|
|
472
|
+
scrub(s).contains("[redacted:"),
|
|
473
|
+
"the input really is redacted"
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#[test]
|
|
478
|
+
fn the_marker_names_the_pattern_that_fired() {
|
|
479
|
+
// Redaction must be visible, not a silent deletion.
|
|
480
|
+
let out = scrub("ghp_abcdefghijklmnopqrstuvwxyz0123");
|
|
481
|
+
assert_eq!(out, "[redacted:github-token]");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#[test]
|
|
485
|
+
fn scrubs_nested_event_payloads() {
|
|
486
|
+
let mut v = json!({
|
|
487
|
+
"type": "tool_use",
|
|
488
|
+
"input": { "command": "curl -H 'Authorization: Bearer sk-abcdefghijklmnopqrstuv'" },
|
|
489
|
+
"nested": [{ "output": "ghp_abcdefghijklmnopqrstuvwxyz0123" }]
|
|
490
|
+
});
|
|
491
|
+
let n = scrub_value(&mut v, Redact::Minimal);
|
|
492
|
+
assert!(n >= 2, "expected both leaves scrubbed, got {n}");
|
|
493
|
+
let text = v.to_string();
|
|
494
|
+
assert!(!text.contains("ghp_abcdefghij"));
|
|
495
|
+
assert!(!text.contains("sk-abcdefghij"));
|
|
496
|
+
// Structure is untouched.
|
|
497
|
+
assert_eq!(v["type"], "tool_use");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#[test]
|
|
501
|
+
fn off_mode_changes_nothing() {
|
|
502
|
+
let mut v = json!({"output": "ghp_abcdefghijklmnopqrstuvwxyz0123"});
|
|
503
|
+
assert_eq!(scrub_value(&mut v, Redact::Off), 0);
|
|
504
|
+
assert_eq!(v["output"], "ghp_abcdefghijklmnopqrstuvwxyz0123");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#[test]
|
|
508
|
+
fn multibyte_text_survives() {
|
|
509
|
+
// Slicing on a non-char-boundary would panic and take the task down.
|
|
510
|
+
let s = "héllo sk-abcdefghijklmnopqrstuv wörld → ✓";
|
|
511
|
+
let out = scrub(s);
|
|
512
|
+
assert!(out.contains("héllo"));
|
|
513
|
+
assert!(out.contains("wörld → ✓"));
|
|
514
|
+
assert!(out.contains("[redacted:"));
|
|
515
|
+
}
|
|
516
|
+
}
|