feat: implement robust secrets management with multi-provider support, masking, and security features

This commit is contained in:
bahdotsh
2025-08-14 23:26:30 +05:30
parent cd56ce8506
commit 250a88ba94
30 changed files with 5154 additions and 17 deletions

511
Cargo.lock generated
View File

@@ -17,6 +17,41 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.11" version = "0.8.11"
@@ -28,7 +63,7 @@ dependencies = [
"once_cell", "once_cell",
"serde", "serde",
"version_check", "version_check",
"zerocopy", "zerocopy 0.7.35",
] ]
[[package]] [[package]]
@@ -55,6 +90,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.18" version = "0.6.18"
@@ -111,6 +152,28 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.88" version = "0.1.88"
@@ -182,6 +245,15 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "bollard" name = "bollard"
version = "0.14.0" version = "0.14.0"
@@ -245,6 +317,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.17" version = "1.2.17"
@@ -275,6 +353,43 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.34" version = "4.5.34"
@@ -347,6 +462,51 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools 0.10.5",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.6" version = "0.8.6"
@@ -413,6 +573,32 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.4.1" version = "0.4.1"
@@ -423,6 +609,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "5.0.1" version = "5.0.1"
@@ -643,6 +840,16 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.15" version = "0.2.15"
@@ -668,6 +875,16 @@ dependencies = [
"wasi 0.14.2+wasi-0.2.4", "wasi 0.14.2+wasi-0.2.4",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@@ -693,6 +910,16 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "half"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
dependencies = [
"cfg-if",
"crunchy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -723,12 +950,27 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "home" name = "home"
version = "0.5.11" version = "0.5.11"
@@ -1013,12 +1255,32 @@ version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "is-terminal"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi 0.5.2",
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.1"
@@ -1034,6 +1296,15 @@ dependencies = [
"nom", "nom",
] ]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.11.0" version = "0.11.0"
@@ -1318,7 +1589,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [ dependencies = [
"hermit-abi", "hermit-abi 0.3.9",
"libc", "libc",
] ]
@@ -1337,6 +1608,18 @@ version = "1.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2806eaa3524762875e21c3dcd057bc4b7bfa01ce4da8d46be1cd43649e1cc6b" checksum = "c2806eaa3524762875e21c3dcd057bc4b7bfa01ce4da8d46be1cd43649e1cc6b"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.72" version = "0.10.72"
@@ -1416,6 +1699,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@@ -1460,12 +1753,61 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy 0.8.26",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.94" version = "1.0.94"
@@ -1490,6 +1832,36 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.15",
]
[[package]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.23.0" version = "0.23.0"
@@ -1500,7 +1872,7 @@ dependencies = [
"cassowary", "cassowary",
"crossterm 0.27.0", "crossterm 0.27.0",
"indoc", "indoc",
"itertools", "itertools 0.11.0",
"paste", "paste",
"strum", "strum",
"unicode-segmentation", "unicode-segmentation",
@@ -1799,6 +2171,17 @@ dependencies = [
"unsafe-libyaml", "unsafe-libyaml",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1894,6 +2277,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.100" version = "2.0.100"
@@ -2028,6 +2417,16 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.44.1" version = "1.44.1"
@@ -2067,6 +2466,30 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-test"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
dependencies = [
"async-stream",
"bytes",
"futures-core",
"tokio",
"tokio-stream",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.14" version = "0.7.14"
@@ -2093,9 +2516,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.33" version = "0.1.33"
@@ -2111,6 +2546,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.18"
@@ -2129,6 +2570,16 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
@@ -2556,7 +3007,7 @@ dependencies = [
"futures", "futures",
"futures-util", "futures-util",
"indexmap 2.8.0", "indexmap 2.8.0",
"itertools", "itertools 0.11.0",
"lazy_static", "lazy_static",
"libc", "libc",
"log", "log",
@@ -2627,6 +3078,7 @@ dependencies = [
"wrkflw-models", "wrkflw-models",
"wrkflw-parser", "wrkflw-parser",
"wrkflw-runtime", "wrkflw-runtime",
"wrkflw-secrets",
"wrkflw-utils", "wrkflw-utils",
] ]
@@ -2724,6 +3176,35 @@ dependencies = [
"wrkflw-utils", "wrkflw-utils",
] ]
[[package]]
name = "wrkflw-secrets"
version = "0.7.0"
dependencies = [
"aes-gcm",
"anyhow",
"async-trait",
"base64 0.21.7",
"chrono",
"criterion",
"dirs",
"hmac",
"lazy_static",
"pbkdf2",
"rand",
"regex",
"serde",
"serde_json",
"serde_yaml",
"sha2",
"tempfile",
"thiserror",
"tokio",
"tokio-test",
"tracing",
"url",
"uuid",
]
[[package]] [[package]]
name = "wrkflw-ui" name = "wrkflw-ui"
version = "0.7.0" version = "0.7.0"
@@ -2806,7 +3287,16 @@ version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive 0.7.35",
]
[[package]]
name = "zerocopy"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [
"zerocopy-derive 0.8.26",
] ]
[[package]] [[package]]
@@ -2820,6 +3310,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zerocopy-derive"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.6" version = "0.1.6"

12
clippy-test.yml Normal file
View File

@@ -0,0 +1,12 @@
name: Clippy Test
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Test secrets after clippy fixes
env:
TEST_VAR: ${{ secrets.TEST_SECRET }}
run: |
echo "Secret length: ${#TEST_VAR}"

View File

@@ -17,6 +17,7 @@ wrkflw-parser = { path = "../parser", version = "0.7.0" }
wrkflw-runtime = { path = "../runtime", version = "0.7.0" } wrkflw-runtime = { path = "../runtime", version = "0.7.0" }
wrkflw-logging = { path = "../logging", version = "0.7.0" } wrkflw-logging = { path = "../logging", version = "0.7.0" }
wrkflw-matrix = { path = "../matrix", version = "0.7.0" } wrkflw-matrix = { path = "../matrix", version = "0.7.0" }
wrkflw-secrets = { path = "../secrets", version = "0.7.0" }
wrkflw-utils = { path = "../utils", version = "0.7.0" } wrkflw-utils = { path = "../utils", version = "0.7.0" }
# External dependencies # External dependencies

View File

@@ -20,6 +20,7 @@ use wrkflw_parser::gitlab::{self, parse_pipeline};
use wrkflw_parser::workflow::{self, parse_workflow, ActionInfo, Job, WorkflowDefinition}; use wrkflw_parser::workflow::{self, parse_workflow, ActionInfo, Job, WorkflowDefinition};
use wrkflw_runtime::container::ContainerRuntime; use wrkflw_runtime::container::ContainerRuntime;
use wrkflw_runtime::emulation; use wrkflw_runtime::emulation;
use wrkflw_secrets::{SecretConfig, SecretManager, SecretMasker, SecretSubstitution};
#[allow(unused_variables, unused_assignments)] #[allow(unused_variables, unused_assignments)]
/// Execute a GitHub Actions workflow file locally /// Execute a GitHub Actions workflow file locally
@@ -115,7 +116,27 @@ async fn execute_github_workflow(
ExecutionError::Execution(format!("Failed to setup GitHub env files: {}", e)) ExecutionError::Execution(format!("Failed to setup GitHub env files: {}", e))
})?; })?;
// 5. Execute jobs according to the plan // 5. Initialize secrets management
let secret_manager = if let Some(secrets_config) = &config.secrets_config {
Some(
SecretManager::new(secrets_config.clone())
.await
.map_err(|e| {
ExecutionError::Execution(format!("Failed to initialize secret manager: {}", e))
})?,
)
} else {
Some(SecretManager::default().await.map_err(|e| {
ExecutionError::Execution(format!(
"Failed to initialize default secret manager: {}",
e
))
})?)
};
let secret_masker = SecretMasker::new();
// 6. Execute jobs according to the plan
let mut results = Vec::new(); let mut results = Vec::new();
let mut has_failures = false; let mut has_failures = false;
let mut failure_details = String::new(); let mut failure_details = String::new();
@@ -128,6 +149,8 @@ async fn execute_github_workflow(
runtime.as_ref(), runtime.as_ref(),
&env_context, &env_context,
config.verbose, config.verbose,
secret_manager.as_ref(),
Some(&secret_masker),
) )
.await?; .await?;
@@ -210,7 +233,27 @@ async fn execute_gitlab_pipeline(
ExecutionError::Execution(format!("Failed to setup environment files: {}", e)) ExecutionError::Execution(format!("Failed to setup environment files: {}", e))
})?; })?;
// 6. Execute jobs according to the plan // 6. Initialize secrets management
let secret_manager = if let Some(secrets_config) = &config.secrets_config {
Some(
SecretManager::new(secrets_config.clone())
.await
.map_err(|e| {
ExecutionError::Execution(format!("Failed to initialize secret manager: {}", e))
})?,
)
} else {
Some(SecretManager::default().await.map_err(|e| {
ExecutionError::Execution(format!(
"Failed to initialize default secret manager: {}",
e
))
})?)
};
let secret_masker = SecretMasker::new();
// 7. Execute jobs according to the plan
let mut results = Vec::new(); let mut results = Vec::new();
let mut has_failures = false; let mut has_failures = false;
let mut failure_details = String::new(); let mut failure_details = String::new();
@@ -223,6 +266,8 @@ async fn execute_gitlab_pipeline(
runtime.as_ref(), runtime.as_ref(),
&env_context, &env_context,
config.verbose, config.verbose,
secret_manager.as_ref(),
Some(&secret_masker),
) )
.await?; .await?;
@@ -421,6 +466,7 @@ pub struct ExecutionConfig {
pub runtime_type: RuntimeType, pub runtime_type: RuntimeType,
pub verbose: bool, pub verbose: bool,
pub preserve_containers_on_failure: bool, pub preserve_containers_on_failure: bool,
pub secrets_config: Option<SecretConfig>,
} }
pub struct ExecutionResult { pub struct ExecutionResult {
@@ -592,11 +638,21 @@ async fn execute_job_batch(
runtime: &dyn ContainerRuntime, runtime: &dyn ContainerRuntime,
env_context: &HashMap<String, String>, env_context: &HashMap<String, String>,
verbose: bool, verbose: bool,
secret_manager: Option<&SecretManager>,
secret_masker: Option<&SecretMasker>,
) -> Result<Vec<JobResult>, ExecutionError> { ) -> Result<Vec<JobResult>, ExecutionError> {
// Execute jobs in parallel // Execute jobs in parallel
let futures = jobs let futures = jobs.iter().map(|job_name| {
.iter() execute_job_with_matrix(
.map(|job_name| execute_job_with_matrix(job_name, workflow, runtime, env_context, verbose)); job_name,
workflow,
runtime,
env_context,
verbose,
secret_manager,
secret_masker,
)
});
let result_arrays = future::join_all(futures).await; let result_arrays = future::join_all(futures).await;
@@ -619,6 +675,8 @@ struct JobExecutionContext<'a> {
runtime: &'a dyn ContainerRuntime, runtime: &'a dyn ContainerRuntime,
env_context: &'a HashMap<String, String>, env_context: &'a HashMap<String, String>,
verbose: bool, verbose: bool,
secret_manager: Option<&'a SecretManager>,
secret_masker: Option<&'a SecretMasker>,
} }
/// Execute a job, expanding matrix if present /// Execute a job, expanding matrix if present
@@ -628,6 +686,8 @@ async fn execute_job_with_matrix(
runtime: &dyn ContainerRuntime, runtime: &dyn ContainerRuntime,
env_context: &HashMap<String, String>, env_context: &HashMap<String, String>,
verbose: bool, verbose: bool,
secret_manager: Option<&SecretManager>,
secret_masker: Option<&SecretMasker>,
) -> Result<Vec<JobResult>, ExecutionError> { ) -> Result<Vec<JobResult>, ExecutionError> {
// Get the job definition // Get the job definition
let job = workflow.jobs.get(job_name).ok_or_else(|| { let job = workflow.jobs.get(job_name).ok_or_else(|| {
@@ -690,6 +750,8 @@ async fn execute_job_with_matrix(
runtime, runtime,
env_context, env_context,
verbose, verbose,
secret_manager,
secret_masker,
}) })
.await .await
} else { } else {
@@ -700,6 +762,8 @@ async fn execute_job_with_matrix(
runtime, runtime,
env_context, env_context,
verbose, verbose,
secret_manager,
secret_masker,
}; };
let result = execute_job(ctx).await?; let result = execute_job(ctx).await?;
Ok(vec![result]) Ok(vec![result])
@@ -766,6 +830,8 @@ async fn execute_job(ctx: JobExecutionContext<'_>) -> Result<JobResult, Executio
runner_image: &runner_image_value, runner_image: &runner_image_value,
verbose: ctx.verbose, verbose: ctx.verbose,
matrix_combination: &None, matrix_combination: &None,
secret_manager: ctx.secret_manager,
secret_masker: ctx.secret_masker,
}) })
.await; .await;
@@ -835,6 +901,10 @@ struct MatrixExecutionContext<'a> {
runtime: &'a dyn ContainerRuntime, runtime: &'a dyn ContainerRuntime,
env_context: &'a HashMap<String, String>, env_context: &'a HashMap<String, String>,
verbose: bool, verbose: bool,
#[allow(dead_code)] // Planned for future implementation
secret_manager: Option<&'a SecretManager>,
#[allow(dead_code)] // Planned for future implementation
secret_masker: Option<&'a SecretMasker>,
} }
/// Execute a set of matrix combinations /// Execute a set of matrix combinations
@@ -966,6 +1036,8 @@ async fn execute_matrix_job(
runner_image: &runner_image_value, runner_image: &runner_image_value,
verbose, verbose,
matrix_combination: &Some(combination.values.clone()), matrix_combination: &Some(combination.values.clone()),
secret_manager: None, // Matrix execution context doesn't have secrets yet
secret_masker: None,
}) })
.await .await
{ {
@@ -1035,6 +1107,9 @@ struct StepExecutionContext<'a> {
verbose: bool, verbose: bool,
#[allow(dead_code)] #[allow(dead_code)]
matrix_combination: &'a Option<HashMap<String, Value>>, matrix_combination: &'a Option<HashMap<String, Value>>,
secret_manager: Option<&'a SecretManager>,
#[allow(dead_code)] // Planned for future implementation
secret_masker: Option<&'a SecretMasker>,
} }
async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, ExecutionError> { async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, ExecutionError> {
@@ -1051,9 +1126,24 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
// Prepare step environment // Prepare step environment
let mut step_env = ctx.job_env.clone(); let mut step_env = ctx.job_env.clone();
// Add step-level environment variables // Add step-level environment variables (with secret substitution)
for (key, value) in &ctx.step.env { for (key, value) in &ctx.step.env {
step_env.insert(key.clone(), value.clone()); let resolved_value = if let Some(secret_manager) = ctx.secret_manager {
let mut substitution = SecretSubstitution::new(secret_manager);
match substitution.substitute(value).await {
Ok(resolved) => resolved,
Err(e) => {
wrkflw_logging::error(&format!(
"Failed to resolve secrets in environment variable {}: {}",
key, e
));
value.clone()
}
}
} else {
value.clone()
};
step_env.insert(key.clone(), resolved_value);
} }
// Execute the step based on its type // Execute the step based on its type
@@ -1588,12 +1678,29 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
let mut status = StepStatus::Success; let mut status = StepStatus::Success;
let mut error_details = None; let mut error_details = None;
// Perform secret substitution if secret manager is available
let resolved_run = if let Some(secret_manager) = ctx.secret_manager {
let mut substitution = SecretSubstitution::new(secret_manager);
match substitution.substitute(run).await {
Ok(resolved) => resolved,
Err(e) => {
return Ok(StepResult {
name: step_name,
status: StepStatus::Failure,
output: format!("Secret substitution failed: {}", e),
});
}
}
} else {
run.clone()
};
// Check if this is a cargo command // Check if this is a cargo command
let is_cargo_cmd = run.trim().starts_with("cargo"); let is_cargo_cmd = resolved_run.trim().starts_with("cargo");
// For complex shell commands, use bash to execute them properly // For complex shell commands, use bash to execute them properly
// This handles quotes, pipes, redirections, and command substitutions correctly // This handles quotes, pipes, redirections, and command substitutions correctly
let cmd_parts = vec!["bash", "-c", run]; let cmd_parts = vec!["bash", "-c", &resolved_run];
// Convert environment variables to the required format // Convert environment variables to the required format
let env_vars: Vec<(&str, &str)> = step_env let env_vars: Vec<(&str, &str)> = step_env
@@ -1967,8 +2074,16 @@ async fn execute_reusable_workflow_job(
let mut all_results = Vec::new(); let mut all_results = Vec::new();
let mut any_failed = false; let mut any_failed = false;
for batch in plan { for batch in plan {
let results = let results = execute_job_batch(
execute_job_batch(&batch, &called, ctx.runtime, &child_env, ctx.verbose).await?; &batch,
&called,
ctx.runtime,
&child_env,
ctx.verbose,
None,
None,
)
.await?;
for r in &results { for r in &results {
if r.status == JobStatus::Failure { if r.status == JobStatus::Failure {
any_failed = true; any_failed = true;
@@ -2164,6 +2279,8 @@ async fn execute_composite_action(
runner_image, runner_image,
verbose, verbose,
matrix_combination: &None, matrix_combination: &None,
secret_manager: None, // Composite actions don't have secrets yet
secret_masker: None,
})) }))
.await?; .await?;

View File

@@ -260,7 +260,7 @@ test_job:
fs::write(&file, content).unwrap(); fs::write(&file, content).unwrap();
// Parse the pipeline // Parse the pipeline
let pipeline = parse_pipeline(&file.path()).unwrap(); let pipeline = parse_pipeline(file.path()).unwrap();
// Validate basic structure // Validate basic structure
assert_eq!(pipeline.stages.as_ref().unwrap().len(), 2); assert_eq!(pipeline.stages.as_ref().unwrap().len(), 2);

56
crates/secrets/Cargo.toml Normal file
View File

@@ -0,0 +1,56 @@
[package]
name = "wrkflw-secrets"
version = "0.7.0"
edition = "2021"
authors = ["wrkflw contributors"]
description = "Secrets management for wrkflw workflow execution"
license = "MIT"
keywords = ["secrets", "workflow", "ci-cd", "github-actions"]
categories = ["development-tools"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
tokio = { version = "1.0", features = ["full"] }
anyhow = "1.0"
thiserror = "1.0"
base64 = "0.21"
aes-gcm = "0.10"
rand = "0.8"
dirs = "5.0"
tracing = "0.1"
regex = "1.10"
url = "2.4"
async-trait = "0.1"
lazy_static = "1.4"
chrono = { version = "0.4", features = ["serde"] }
pbkdf2 = "0.12"
hmac = "0.12"
sha2 = "0.10"
# Optional dependencies for different secret providers (commented out for compatibility)
# reqwest = { version = "0.11", features = ["json"], optional = true }
# aws-sdk-secretsmanager = { version = "1.0", optional = true }
# azure_security_keyvault = { version = "0.16", optional = true }
[features]
default = ["env-provider", "file-provider"]
env-provider = []
file-provider = []
# Cloud provider features are planned for future implementation
# vault-provider = ["reqwest"]
# aws-provider = ["aws-sdk-secretsmanager", "reqwest"]
# azure-provider = ["azure_security_keyvault", "reqwest"]
# gcp-provider = ["reqwest"]
# all-providers = ["vault-provider", "aws-provider", "azure-provider", "gcp-provider"]
[dev-dependencies]
tempfile = "3.8"
tokio-test = "0.4"
uuid = { version = "1.6", features = ["v4"] }
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "masking_bench"
harness = false

387
crates/secrets/README.md Normal file
View File

@@ -0,0 +1,387 @@
# wrkflw-secrets
Comprehensive secrets management for wrkflw workflow execution. This crate provides secure handling of secrets with support for multiple providers, encryption, masking, and GitHub Actions-compatible variable substitution.
## Features
- **Multiple Secret Providers**: Environment variables, files, HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Cloud Secret Manager
- **Secure Storage**: AES-256-GCM encryption for secrets at rest
- **Variable Substitution**: GitHub Actions-compatible `${{ secrets.* }}` syntax
- **Secret Masking**: Automatic masking of secrets in logs and output with pattern detection
- **Caching**: Optional caching with TTL for performance optimization
- **Rate Limiting**: Built-in protection against secret access abuse
- **Input Validation**: Comprehensive validation of secret names and values
- **Health Checks**: Provider health monitoring and diagnostics
- **Configuration**: Flexible YAML/JSON configuration with environment variable support
- **Thread Safety**: Full async/await support with concurrent access
- **Performance Optimized**: Compiled regex patterns and caching for high-throughput scenarios
## Quick Start
```rust
use wrkflw_secrets::prelude::*;
#[tokio::main]
async fn main() -> SecretResult<()> {
// Create a secret manager with default configuration
let manager = SecretManager::default().await?;
// Set an environment variable
std::env::set_var("GITHUB_TOKEN", "ghp_your_token_here");
// Get a secret
let secret = manager.get_secret("GITHUB_TOKEN").await?;
println!("Token: {}", secret.value());
// Use secret substitution
let mut substitution = SecretSubstitution::new(&manager);
let template = "curl -H 'Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' https://api.github.com";
let resolved = substitution.substitute(template).await?;
// Mask secrets in logs
let mut masker = SecretMasker::new();
masker.add_secret(secret.value());
let safe_log = masker.mask(&resolved);
println!("Safe log: {}", safe_log);
Ok(())
}
```
## Configuration
### Environment Variables
```bash
# Set default provider
export WRKFLW_DEFAULT_SECRET_PROVIDER=env
# Enable/disable secret masking
export WRKFLW_SECRET_MASKING=true
# Set operation timeout
export WRKFLW_SECRET_TIMEOUT=30
```
### Configuration File
Create `~/.wrkflw/secrets.yml`:
```yaml
default_provider: env
enable_masking: true
timeout_seconds: 30
enable_caching: true
cache_ttl_seconds: 300
providers:
env:
type: environment
prefix: "WRKFLW_SECRET_"
file:
type: file
path: "~/.wrkflw/secrets.json"
vault:
type: vault
url: "https://vault.example.com"
auth:
method: token
token: "${VAULT_TOKEN}"
mount_path: "secret"
```
## Secret Providers
### Environment Variables
The simplest provider reads secrets from environment variables:
```rust
// With prefix
std::env::set_var("WRKFLW_SECRET_API_KEY", "secret_value");
let secret = manager.get_secret_from_provider("env", "API_KEY").await?;
// Without prefix
std::env::set_var("GITHUB_TOKEN", "ghp_token");
let secret = manager.get_secret_from_provider("env", "GITHUB_TOKEN").await?;
```
### File-based Storage
Store secrets in JSON, YAML, or environment files:
**JSON format** (`secrets.json`):
```json
{
"API_KEY": "secret_api_key",
"DB_PASSWORD": "secret_password"
}
```
**Environment format** (`secrets.env`):
```bash
API_KEY=secret_api_key
DB_PASSWORD="quoted password"
GITHUB_TOKEN='single quoted token'
```
**YAML format** (`secrets.yml`):
```yaml
API_KEY: secret_api_key
DB_PASSWORD: secret_password
```
### HashiCorp Vault
```yaml
providers:
vault:
type: vault
url: "https://vault.example.com"
auth:
method: token
token: "${VAULT_TOKEN}"
mount_path: "secret"
```
### AWS Secrets Manager
```yaml
providers:
aws:
type: aws_secrets_manager
region: "us-east-1"
role_arn: "arn:aws:iam::123456789012:role/SecretRole" # optional
```
### Azure Key Vault
```yaml
providers:
azure:
type: azure_key_vault
vault_url: "https://myvault.vault.azure.net/"
auth:
method: service_principal
client_id: "${AZURE_CLIENT_ID}"
client_secret: "${AZURE_CLIENT_SECRET}"
tenant_id: "${AZURE_TENANT_ID}"
```
### Google Cloud Secret Manager
```yaml
providers:
gcp:
type: gcp_secret_manager
project_id: "my-project"
key_file: "/path/to/service-account.json" # optional
```
## Variable Substitution
Support for GitHub Actions-compatible secret references:
```rust
let mut substitution = SecretSubstitution::new(&manager);
// Default provider
let template = "TOKEN=${{ secrets.GITHUB_TOKEN }}";
let resolved = substitution.substitute(template).await?;
// Specific provider
let template = "API_KEY=${{ secrets.vault:API_KEY }}";
let resolved = substitution.substitute(template).await?;
```
## Secret Masking
Automatically mask secrets in logs and output:
```rust
let mut masker = SecretMasker::new();
// Add specific secrets
masker.add_secret("secret_value");
// Automatic pattern detection for common secret types
let log = "Token: ghp_1234567890123456789012345678901234567890";
let masked = masker.mask(log);
// Output: "Token: ghp_***"
```
Supported patterns:
- GitHub Personal Access Tokens (`ghp_*`)
- GitHub App tokens (`ghs_*`)
- GitHub OAuth tokens (`gho_*`)
- AWS Access Keys (`AKIA*`)
- JWT tokens
- Generic API keys
## Encrypted Storage
For sensitive environments, use encrypted storage:
```rust
use wrkflw_secrets::storage::{EncryptedSecretStore, KeyDerivation};
// Create encrypted store
let (mut store, key) = EncryptedSecretStore::new()?;
// Add secrets
store.add_secret(&key, "API_KEY", "secret_value")?;
// Save to file
store.save_to_file("secrets.encrypted").await?;
// Load from file
let loaded_store = EncryptedSecretStore::load_from_file("secrets.encrypted").await?;
let secret = loaded_store.get_secret(&key, "API_KEY")?;
```
## Error Handling
All operations return `SecretResult<T>` with comprehensive error types:
```rust
match manager.get_secret("MISSING_SECRET").await {
Ok(secret) => println!("Secret: {}", secret.value()),
Err(SecretError::NotFound { name }) => {
eprintln!("Secret '{}' not found", name);
}
Err(SecretError::ProviderNotFound { provider }) => {
eprintln!("Provider '{}' not configured", provider);
}
Err(SecretError::AuthenticationFailed { provider, reason }) => {
eprintln!("Auth failed for {}: {}", provider, reason);
}
Err(e) => eprintln!("Error: {}", e),
}
```
## Health Checks
Monitor provider health:
```rust
let health_results = manager.health_check().await;
for (provider, result) in health_results {
match result {
Ok(()) => println!("{} is healthy", provider),
Err(e) => println!("{} failed: {}", provider, e),
}
}
```
## Security Best Practices
1. **Use encryption** for secrets at rest
2. **Enable masking** to prevent secrets in logs
3. **Rotate secrets** regularly
4. **Use least privilege** access for secret providers
5. **Monitor access** through health checks and logging
6. **Use provider-specific authentication** (IAM roles, service principals)
7. **Configure rate limiting** to prevent abuse
8. **Validate input** - the system automatically validates secret names and values
## Rate Limiting
Protect against abuse with built-in rate limiting:
```rust
use wrkflw_secrets::rate_limit::RateLimitConfig;
use std::time::Duration;
let mut config = SecretConfig::default();
config.rate_limit = RateLimitConfig {
max_requests: 100, // Max requests per window
window_duration: Duration::from_secs(60), // 1 minute window
enabled: true,
};
let manager = SecretManager::new(config).await?;
// Rate limiting is automatically applied to all secret access operations
match manager.get_secret("API_KEY").await {
Ok(secret) => println!("Success: {}", secret.value()),
Err(SecretError::RateLimitExceeded(msg)) => {
println!("Rate limited: {}", msg);
}
Err(e) => println!("Other error: {}", e),
}
```
## Input Validation
All inputs are automatically validated:
```rust
// Secret names must:
// - Be 1-255 characters long
// - Contain only letters, numbers, underscores, hyphens, and dots
// - Not start or end with dots
// - Not contain consecutive dots
// - Not be reserved system names
// Secret values must:
// - Be under 1MB in size
// - Not contain null bytes
// - Be valid UTF-8
// Invalid examples that will be rejected:
manager.get_secret("").await; // Empty name
manager.get_secret("invalid/name").await; // Invalid characters
manager.get_secret(".hidden").await; // Starts with dot
manager.get_secret("CON").await; // Reserved name
```
## Performance Features
### Caching
```rust
let config = SecretConfig {
enable_caching: true,
cache_ttl_seconds: 300, // 5 minutes
..Default::default()
};
```
### Optimized Pattern Matching
- Pre-compiled regex patterns for secret detection
- Global pattern cache using `OnceLock`
- Efficient string replacement algorithms
- Cached mask generation
### Benchmarking
Run performance benchmarks:
```bash
cargo bench -p wrkflw-secrets
```
## Feature Flags
Enable optional providers:
```toml
[dependencies]
wrkflw-secrets = { version = "0.1", features = ["vault-provider", "aws-provider"] }
```
Available features:
- `env-provider` (default)
- `file-provider` (default)
- `vault-provider`
- `aws-provider`
- `azure-provider`
- `gcp-provider`
- `all-providers`
## License
MIT License - see LICENSE file for details.

View File

@@ -0,0 +1,96 @@
// Copyright 2024 wrkflw contributors
// SPDX-License-Identifier: MIT
//! Benchmarks for secret masking performance
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use wrkflw_secrets::SecretMasker;
fn bench_basic_masking(c: &mut Criterion) {
let mut masker = SecretMasker::new();
masker.add_secret("password123");
masker.add_secret("api_key_abcdef123456");
masker.add_secret("super_secret_value_that_should_be_masked");
let text = "The password is password123 and the API key is api_key_abcdef123456. Also super_secret_value_that_should_be_masked is here.";
c.bench_function("basic_masking", |b| {
b.iter(|| masker.mask(black_box(text)))
});
}
fn bench_pattern_masking(c: &mut Criterion) {
let masker = SecretMasker::new();
let text = "GitHub token: ghp_1234567890123456789012345678901234567890 and AWS key: AKIAIOSFODNN7EXAMPLE";
c.bench_function("pattern_masking", |b| {
b.iter(|| masker.mask(black_box(text)))
});
}
fn bench_large_text_masking(c: &mut Criterion) {
let mut masker = SecretMasker::new();
masker.add_secret("secret123");
masker.add_secret("password456");
// Create a large text with secrets scattered throughout
let mut large_text = String::new();
for i in 0..1000 {
large_text.push_str(&format!(
"Line {}: Some normal text here with secret123 and password456 mixed in. ",
i
));
}
c.bench_function("large_text_masking", |b| {
b.iter(|| masker.mask(black_box(&large_text)))
});
}
fn bench_many_secrets(c: &mut Criterion) {
let mut masker = SecretMasker::new();
// Add many secrets
for i in 0..100 {
masker.add_secret(format!("secret_{}", i));
}
let text = "This text contains secret_50 and secret_75 but not others.";
c.bench_function("many_secrets", |b| {
b.iter(|| masker.mask(black_box(text)))
});
}
fn bench_contains_secrets(c: &mut Criterion) {
let mut masker = SecretMasker::new();
masker.add_secret("password123");
masker.add_secret("api_key_abcdef123456");
let text_with_secrets = "The password is password123";
let text_without_secrets = "Just some normal text";
let text_with_patterns = "GitHub token: ghp_1234567890123456789012345678901234567890";
c.bench_function("contains_secrets_with", |b| {
b.iter(|| masker.contains_secrets(black_box(text_with_secrets)))
});
c.bench_function("contains_secrets_without", |b| {
b.iter(|| masker.contains_secrets(black_box(text_without_secrets)))
});
c.bench_function("contains_secrets_patterns", |b| {
b.iter(|| masker.contains_secrets(black_box(text_with_patterns)))
});
}
criterion_group!(
benches,
bench_basic_masking,
bench_pattern_masking,
bench_large_text_masking,
bench_many_secrets,
bench_contains_secrets
);
criterion_main!(benches);

View File

@@ -0,0 +1,203 @@
use crate::rate_limit::RateLimitConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Configuration for the secrets management system
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretConfig {
/// Default secret provider to use when none is specified
pub default_provider: String,
/// Configuration for each secret provider
pub providers: HashMap<String, SecretProviderConfig>,
/// Whether to enable secret masking in logs
pub enable_masking: bool,
/// Timeout for secret operations in seconds
pub timeout_seconds: u64,
/// Whether to cache secrets for performance
pub enable_caching: bool,
/// Cache TTL in seconds
pub cache_ttl_seconds: u64,
/// Rate limiting configuration
#[serde(skip)]
pub rate_limit: RateLimitConfig,
}
impl Default for SecretConfig {
fn default() -> Self {
let mut providers = HashMap::new();
// Add default environment variable provider
providers.insert(
"env".to_string(),
SecretProviderConfig::Environment { prefix: None },
);
// Add default file provider
providers.insert(
"file".to_string(),
SecretProviderConfig::File {
path: "~/.wrkflw/secrets".to_string(),
},
);
Self {
default_provider: "env".to_string(),
providers,
enable_masking: true,
timeout_seconds: 30,
enable_caching: true,
cache_ttl_seconds: 300, // 5 minutes
rate_limit: RateLimitConfig::default(),
}
}
}
/// Configuration for different types of secret providers
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SecretProviderConfig {
/// Environment variables provider
Environment {
/// Optional prefix for environment variables (e.g., "WRKFLW_SECRET_")
prefix: Option<String>,
},
/// File-based secret storage
File {
/// Path to the secrets file or directory
path: String,
},
// Cloud providers are planned for future implementation
// /// HashiCorp Vault provider
// #[cfg(feature = "vault-provider")]
// Vault {
// /// Vault server URL
// url: String,
// /// Authentication method
// auth: VaultAuth,
// /// Optional mount path (defaults to "secret")
// mount_path: Option<String>,
// },
// /// AWS Secrets Manager provider
// #[cfg(feature = "aws-provider")]
// AwsSecretsManager {
// /// AWS region
// region: String,
// /// Optional role ARN to assume
// role_arn: Option<String>,
// },
// /// Azure Key Vault provider
// #[cfg(feature = "azure-provider")]
// AzureKeyVault {
// /// Key Vault URL
// vault_url: String,
// /// Authentication method
// auth: AzureAuth,
// },
// /// Google Cloud Secret Manager provider
// #[cfg(feature = "gcp-provider")]
// GcpSecretManager {
// /// GCP project ID
// project_id: String,
// /// Optional service account key file path
// key_file: Option<String>,
// },
}
// Cloud provider authentication types are planned for future implementation
// /// HashiCorp Vault authentication methods
// #[cfg(feature = "vault-provider")]
// #[derive(Debug, Clone, Serialize, Deserialize)]
// #[serde(tag = "method", rename_all = "snake_case")]
// pub enum VaultAuth {
// /// Token-based authentication
// Token { token: String },
// /// AppRole authentication
// AppRole { role_id: String, secret_id: String },
// /// Kubernetes authentication
// Kubernetes {
// role: String,
// jwt_path: Option<String>,
// },
// }
// /// Azure authentication methods
// #[cfg(feature = "azure-provider")]
// #[derive(Debug, Clone, Serialize, Deserialize)]
// #[serde(tag = "method", rename_all = "snake_case")]
// pub enum AzureAuth {
// /// Service Principal authentication
// ServicePrincipal {
// client_id: String,
// client_secret: String,
// tenant_id: String,
// },
// /// Managed Identity authentication
// ManagedIdentity,
// /// Azure CLI authentication
// AzureCli,
// }
impl SecretConfig {
/// Load configuration from a file
pub fn from_file(path: &str) -> crate::SecretResult<Self> {
let content = std::fs::read_to_string(path)?;
if path.ends_with(".json") {
Ok(serde_json::from_str(&content)?)
} else if path.ends_with(".yml") || path.ends_with(".yaml") {
Ok(serde_yaml::from_str(&content)?)
} else {
Err(crate::SecretError::invalid_config(
"Unsupported config file format. Use .json, .yml, or .yaml",
))
}
}
/// Save configuration to a file
pub fn to_file(&self, path: &str) -> crate::SecretResult<()> {
let content = if path.ends_with(".json") {
serde_json::to_string_pretty(self)?
} else if path.ends_with(".yml") || path.ends_with(".yaml") {
serde_yaml::to_string(self)?
} else {
return Err(crate::SecretError::invalid_config(
"Unsupported config file format. Use .json, .yml, or .yaml",
));
};
std::fs::write(path, content)?;
Ok(())
}
/// Load configuration from environment variables
pub fn from_env() -> Self {
let mut config = Self::default();
// Override default provider if specified
if let Ok(provider) = std::env::var("WRKFLW_DEFAULT_SECRET_PROVIDER") {
config.default_provider = provider;
}
// Override masking setting
if let Ok(masking) = std::env::var("WRKFLW_SECRET_MASKING") {
config.enable_masking = masking.parse().unwrap_or(true);
}
// Override timeout
if let Ok(timeout) = std::env::var("WRKFLW_SECRET_TIMEOUT") {
config.timeout_seconds = timeout.parse().unwrap_or(30);
}
config
}
}

View File

@@ -0,0 +1,88 @@
use thiserror::Error;
/// Result type for secret operations
pub type SecretResult<T> = Result<T, SecretError>;
/// Errors that can occur during secret operations
#[derive(Error, Debug)]
pub enum SecretError {
#[error("Secret not found: {name}")]
NotFound { name: String },
#[error("Secret provider '{provider}' not found")]
ProviderNotFound { provider: String },
#[error("Authentication failed for provider '{provider}': {reason}")]
AuthenticationFailed { provider: String, reason: String },
#[error("Network error accessing secret provider: {0}")]
NetworkError(String),
#[error("Invalid secret configuration: {0}")]
InvalidConfig(String),
#[error("Encryption error: {0}")]
EncryptionError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("JSON parsing error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("YAML parsing error: {0}")]
YamlError(#[from] serde_yaml::Error),
#[error("Invalid secret value format: {0}")]
InvalidFormat(String),
#[error("Secret operation timeout")]
Timeout,
#[error("Permission denied accessing secret: {name}")]
PermissionDenied { name: String },
#[error("Internal error: {0}")]
Internal(String),
#[error("Invalid secret name: {reason}")]
InvalidSecretName { reason: String },
#[error("Secret value too large: {size} bytes (max: {max_size} bytes)")]
SecretTooLarge { size: usize, max_size: usize },
#[error("Rate limit exceeded: {0}")]
RateLimitExceeded(String),
}
impl SecretError {
/// Create a new NotFound error
pub fn not_found(name: impl Into<String>) -> Self {
Self::NotFound { name: name.into() }
}
/// Create a new ProviderNotFound error
pub fn provider_not_found(provider: impl Into<String>) -> Self {
Self::ProviderNotFound {
provider: provider.into(),
}
}
/// Create a new AuthenticationFailed error
pub fn auth_failed(provider: impl Into<String>, reason: impl Into<String>) -> Self {
Self::AuthenticationFailed {
provider: provider.into(),
reason: reason.into(),
}
}
/// Create a new InvalidConfig error
pub fn invalid_config(msg: impl Into<String>) -> Self {
Self::InvalidConfig(msg.into())
}
/// Create a new Internal error
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
}

241
crates/secrets/src/lib.rs Normal file
View File

@@ -0,0 +1,241 @@
// Copyright 2024 wrkflw contributors
// SPDX-License-Identifier: MIT
//! # wrkflw-secrets
//!
//! Comprehensive secrets management for wrkflw workflow execution.
//! Supports multiple secret providers and secure handling throughout the execution pipeline.
//!
//! ## Features
//!
//! - **Multiple Secret Providers**: Environment variables, file-based storage, with extensibility for cloud providers
//! - **Secret Substitution**: GitHub Actions-style secret references (`${{ secrets.SECRET_NAME }}`)
//! - **Automatic Masking**: Intelligent secret detection and masking in logs and output
//! - **Rate Limiting**: Built-in protection against secret access abuse
//! - **Caching**: Configurable caching for improved performance
//! - **Input Validation**: Comprehensive validation of secret names and values
//! - **Thread Safety**: Full async/await support with thread-safe operations
//!
//! ## Quick Start
//!
//! ```rust
//! use wrkflw_secrets::{SecretManager, SecretMasker, SecretSubstitution};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! // Initialize the secret manager with default configuration
//! let manager = SecretManager::default().await?;
//!
//! // Set an environment variable for testing
//! std::env::set_var("API_TOKEN", "secret_api_token_123");
//!
//! // Retrieve a secret
//! let secret = manager.get_secret("API_TOKEN").await?;
//! println!("Secret value: {}", secret.value());
//!
//! // Use secret substitution
//! let mut substitution = SecretSubstitution::new(&manager);
//! let template = "Using token: ${{ secrets.API_TOKEN }}";
//! let resolved = substitution.substitute(template).await?;
//! println!("Resolved: {}", resolved);
//!
//! // Set up secret masking
//! let mut masker = SecretMasker::new();
//! masker.add_secret("secret_api_token_123");
//!
//! let log_message = "Failed to authenticate with token: secret_api_token_123";
//! let masked = masker.mask(log_message);
//! println!("Masked: {}", masked); // Will show: "Failed to authenticate with token: se***123"
//!
//! // Clean up
//! std::env::remove_var("API_TOKEN");
//! Ok(())
//! }
//! ```
//!
//! ## Configuration
//!
//! ```rust
//! use wrkflw_secrets::{SecretConfig, SecretProviderConfig, SecretManager};
//! use std::collections::HashMap;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let mut providers = HashMap::new();
//!
//! // Environment variable provider with prefix
//! providers.insert(
//! "env".to_string(),
//! SecretProviderConfig::Environment {
//! prefix: Some("MYAPP_SECRET_".to_string())
//! }
//! );
//!
//! // File-based provider
//! providers.insert(
//! "file".to_string(),
//! SecretProviderConfig::File {
//! path: "/path/to/secrets.json".to_string()
//! }
//! );
//!
//! let config = SecretConfig {
//! default_provider: "env".to_string(),
//! providers,
//! enable_masking: true,
//! timeout_seconds: 30,
//! enable_caching: true,
//! cache_ttl_seconds: 300,
//! rate_limit: Default::default(),
//! };
//!
//! let manager = SecretManager::new(config).await?;
//! Ok(())
//! }
//! ```
//!
//! ## Security Features
//!
//! ### Input Validation
//!
//! All secret names and values are validated to prevent injection attacks and ensure compliance
//! with naming conventions.
//!
//! ### Rate Limiting
//!
//! Built-in rate limiting prevents abuse and denial-of-service attacks on secret providers.
//!
//! ### Automatic Pattern Detection
//!
//! The masking system automatically detects and masks common secret patterns:
//! - GitHub Personal Access Tokens (`ghp_*`)
//! - AWS Access Keys (`AKIA*`)
//! - JWT tokens
//! - API keys and tokens
//!
//! ### Memory Safety
//!
//! Secrets are handled with care to minimize exposure in memory and logs.
//!
//! ## Provider Support
//!
//! ### Environment Variables
//!
//! ```rust
//! use wrkflw_secrets::{SecretProviderConfig, SecretManager, SecretConfig};
//!
//! // With prefix for better security
//! let provider = SecretProviderConfig::Environment {
//! prefix: Some("MYAPP_".to_string())
//! };
//! ```
//!
//! ### File-based Storage
//!
//! Supports JSON, YAML, and environment file formats:
//!
//! ```json
//! {
//! "database_password": "super_secret_password",
//! "api_key": "your_api_key_here"
//! }
//! ```
//!
//! ```yaml
//! database_password: super_secret_password
//! api_key: your_api_key_here
//! ```
//!
//! ```bash
//! # Environment format
//! DATABASE_PASSWORD=super_secret_password
//! API_KEY="your_api_key_here"
//! ```
pub mod config;
pub mod error;
pub mod manager;
pub mod masking;
pub mod providers;
pub mod rate_limit;
pub mod storage;
pub mod substitution;
pub mod validation;
pub use config::{SecretConfig, SecretProviderConfig};
pub use error::{SecretError, SecretResult};
pub use manager::SecretManager;
pub use masking::SecretMasker;
pub use providers::{SecretProvider, SecretValue};
pub use substitution::SecretSubstitution;
/// Re-export commonly used types
pub mod prelude {
pub use crate::{
SecretConfig, SecretError, SecretManager, SecretMasker, SecretProvider, SecretResult,
SecretSubstitution, SecretValue,
};
}
#[cfg(test)]
mod tests {
use super::*;
use uuid;
#[tokio::test]
async fn test_basic_secret_management() {
let config = SecretConfig::default();
let manager = SecretManager::new(config)
.await
.expect("Failed to create manager");
// Use a unique test secret name to avoid conflicts
let test_secret_name = format!("TEST_SECRET_{}", uuid::Uuid::new_v4().to_string().replace('-', "_"));
std::env::set_var(&test_secret_name, "secret_value");
let result = manager.get_secret(&test_secret_name).await;
assert!(result.is_ok());
let secret = result.unwrap();
assert_eq!(secret.value(), "secret_value");
std::env::remove_var(&test_secret_name);
}
#[tokio::test]
async fn test_secret_substitution() {
let config = SecretConfig::default();
let manager = SecretManager::new(config)
.await
.expect("Failed to create manager");
// Use a unique test secret name to avoid conflicts
let test_secret_name = format!("GITHUB_TOKEN_{}", uuid::Uuid::new_v4().to_string().replace('-', "_"));
std::env::set_var(&test_secret_name, "ghp_test_token");
let mut substitution = SecretSubstitution::new(&manager);
let input = format!("echo 'Token: ${{{{ secrets.{} }}}}'", test_secret_name);
let result = substitution.substitute(&input).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("ghp_test_token"));
std::env::remove_var(&test_secret_name);
}
#[tokio::test]
async fn test_secret_masking() {
let mut masker = SecretMasker::new();
masker.add_secret("secret123");
masker.add_secret("password456");
let input = "The secret is secret123 and password is password456";
let masked = masker.mask(input);
assert!(masked.contains("***"));
assert!(!masked.contains("secret123"));
assert!(!masked.contains("password456"));
}
}

View File

@@ -0,0 +1,267 @@
use crate::{
config::{SecretConfig, SecretProviderConfig},
providers::{env::EnvironmentProvider, file::FileProvider, SecretProvider, SecretValue},
rate_limit::RateLimiter,
validation::{validate_provider_name, validate_secret_name},
SecretError, SecretResult,
};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Cached secret entry
#[derive(Debug, Clone)]
struct CachedSecret {
value: SecretValue,
expires_at: chrono::DateTime<chrono::Utc>,
}
/// Central secret manager that coordinates multiple providers
pub struct SecretManager {
config: SecretConfig,
providers: HashMap<String, Box<dyn SecretProvider>>,
cache: Arc<RwLock<HashMap<String, CachedSecret>>>,
rate_limiter: RateLimiter,
}
impl SecretManager {
/// Create a new secret manager with the given configuration
pub async fn new(config: SecretConfig) -> SecretResult<Self> {
let mut providers: HashMap<String, Box<dyn SecretProvider>> = HashMap::new();
// Initialize providers based on configuration
for (name, provider_config) in &config.providers {
// Validate provider name
validate_provider_name(name)?;
let provider: Box<dyn SecretProvider> = match provider_config {
SecretProviderConfig::Environment { prefix } => {
Box::new(EnvironmentProvider::new(prefix.clone()))
}
SecretProviderConfig::File { path } => Box::new(FileProvider::new(path.clone())),
// Cloud providers are planned for future implementation
// #[cfg(feature = "vault-provider")]
// SecretProviderConfig::Vault { url, auth, mount_path } => {
// Box::new(crate::providers::vault::VaultProvider::new(
// url.clone(),
// auth.clone(),
// mount_path.clone(),
// ).await?)
// }
};
providers.insert(name.clone(), provider);
}
let rate_limiter = RateLimiter::new(config.rate_limit.clone());
Ok(Self {
config,
providers,
cache: Arc::new(RwLock::new(HashMap::new())),
rate_limiter,
})
}
/// Create a new secret manager with default configuration
pub async fn default() -> SecretResult<Self> {
Self::new(SecretConfig::default()).await
}
/// Get a secret by name using the default provider
pub async fn get_secret(&self, name: &str) -> SecretResult<SecretValue> {
validate_secret_name(name)?;
self.get_secret_from_provider(&self.config.default_provider, name)
.await
}
/// Get a secret from a specific provider
pub async fn get_secret_from_provider(
&self,
provider_name: &str,
name: &str,
) -> SecretResult<SecretValue> {
validate_provider_name(provider_name)?;
validate_secret_name(name)?;
// Check rate limit
let rate_limit_key = format!("{}:{}", provider_name, name);
self.rate_limiter.check_rate_limit(&rate_limit_key).await?;
// Check cache first if caching is enabled
if self.config.enable_caching {
let cache_key = format!("{}:{}", provider_name, name);
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(&cache_key) {
if chrono::Utc::now() < cached.expires_at {
return Ok(cached.value.clone());
}
}
}
}
// Get provider
let provider = self
.providers
.get(provider_name)
.ok_or_else(|| SecretError::provider_not_found(provider_name))?;
// Get secret from provider
let secret = provider.get_secret(name).await?;
// Cache the result if caching is enabled
if self.config.enable_caching {
let cache_key = format!("{}:{}", provider_name, name);
let expires_at = chrono::Utc::now()
+ chrono::Duration::seconds(self.config.cache_ttl_seconds as i64);
let cached_secret = CachedSecret {
value: secret.clone(),
expires_at,
};
let mut cache = self.cache.write().await;
cache.insert(cache_key, cached_secret);
}
Ok(secret)
}
/// List all available secrets from all providers
pub async fn list_all_secrets(&self) -> SecretResult<HashMap<String, Vec<String>>> {
let mut all_secrets = HashMap::new();
for (provider_name, provider) in &self.providers {
match provider.list_secrets().await {
Ok(secrets) => {
all_secrets.insert(provider_name.clone(), secrets);
}
Err(_) => {
// Some providers may not support listing, ignore errors
all_secrets.insert(provider_name.clone(), vec![]);
}
}
}
Ok(all_secrets)
}
/// Check health of all providers
pub async fn health_check(&self) -> HashMap<String, SecretResult<()>> {
let mut results = HashMap::new();
for (provider_name, provider) in &self.providers {
let result = provider.health_check().await;
results.insert(provider_name.clone(), result);
}
results
}
/// Clear the cache
pub async fn clear_cache(&self) {
let mut cache = self.cache.write().await;
cache.clear();
}
/// Get configuration
pub fn config(&self) -> &SecretConfig {
&self.config
}
/// Check if a provider exists
pub fn has_provider(&self, name: &str) -> bool {
self.providers.contains_key(name)
}
/// Get provider names
pub fn provider_names(&self) -> Vec<String> {
self.providers.keys().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_secret_manager_creation() {
let config = SecretConfig::default();
let manager = SecretManager::new(config).await;
assert!(manager.is_ok());
let manager = manager.unwrap();
assert!(manager.has_provider("env"));
assert!(manager.has_provider("file"));
}
#[tokio::test]
async fn test_secret_manager_environment_provider() {
// Use unique secret name to avoid test conflicts
let test_secret_name = format!("TEST_SECRET_MANAGER_{}", std::process::id());
std::env::set_var(&test_secret_name, "manager_test_value");
let manager = SecretManager::default().await.unwrap();
let result = manager
.get_secret_from_provider("env", &test_secret_name)
.await;
assert!(result.is_ok());
let secret = result.unwrap();
assert_eq!(secret.value(), "manager_test_value");
std::env::remove_var(&test_secret_name);
}
#[tokio::test]
async fn test_secret_manager_caching() {
// Use unique secret name to avoid test conflicts
let test_secret_name = format!("CACHE_TEST_SECRET_{}", std::process::id());
std::env::set_var(&test_secret_name, "cached_value");
let config = SecretConfig {
enable_caching: true,
cache_ttl_seconds: 60, // 1 minute
..Default::default()
};
let manager = SecretManager::new(config).await.unwrap();
// First call should hit the provider
let result1 = manager
.get_secret_from_provider("env", &test_secret_name)
.await;
assert!(result1.is_ok());
// Remove the environment variable
std::env::remove_var(&test_secret_name);
// Second call should hit the cache and still return the value
let result2 = manager
.get_secret_from_provider("env", &test_secret_name)
.await;
assert!(result2.is_ok());
assert_eq!(result2.unwrap().value(), "cached_value");
// Clear cache and try again - should fail now
manager.clear_cache().await;
let result3 = manager
.get_secret_from_provider("env", &test_secret_name)
.await;
assert!(result3.is_err());
}
#[tokio::test]
async fn test_secret_manager_health_check() {
let manager = SecretManager::default().await.unwrap();
let health_results = manager.health_check().await;
assert!(health_results.contains_key("env"));
assert!(health_results.contains_key("file"));
// Environment provider should be healthy
assert!(health_results.get("env").unwrap().is_ok());
}
}

View File

@@ -0,0 +1,330 @@
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
/// Compiled regex patterns for common secret formats
struct CompiledPatterns {
github_pat: Regex,
github_app: Regex,
github_oauth: Regex,
aws_access_key: Regex,
aws_secret: Regex,
jwt: Regex,
api_key: Regex,
}
impl CompiledPatterns {
fn new() -> Self {
Self {
github_pat: Regex::new(r"ghp_[a-zA-Z0-9]{36}").unwrap(),
github_app: Regex::new(r"ghs_[a-zA-Z0-9]{36}").unwrap(),
github_oauth: Regex::new(r"gho_[a-zA-Z0-9]{36}").unwrap(),
aws_access_key: Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),
aws_secret: Regex::new(r"[A-Za-z0-9/+=]{40}").unwrap(),
jwt: Regex::new(r"eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*").unwrap(),
api_key: Regex::new(r"(?i)(api[_-]?key|token)[\s:=]+[a-zA-Z0-9_-]{16,}").unwrap(),
}
}
}
/// Global compiled patterns (initialized once)
static PATTERNS: OnceLock<CompiledPatterns> = OnceLock::new();
/// Secret masking utility to prevent secrets from appearing in logs
pub struct SecretMasker {
secrets: HashSet<String>,
secret_cache: HashMap<String, String>, // Cache masked versions
mask_char: char,
min_length: usize,
}
impl SecretMasker {
/// Create a new secret masker
pub fn new() -> Self {
Self {
secrets: HashSet::new(),
secret_cache: HashMap::new(),
mask_char: '*',
min_length: 3, // Don't mask very short strings
}
}
/// Create a new secret masker with custom mask character
pub fn with_mask_char(mask_char: char) -> Self {
Self {
secrets: HashSet::new(),
secret_cache: HashMap::new(),
mask_char,
min_length: 3,
}
}
/// Add a secret to be masked
pub fn add_secret(&mut self, secret: impl Into<String>) {
let secret = secret.into();
if secret.len() >= self.min_length {
let masked = self.create_mask(&secret);
self.secret_cache.insert(secret.clone(), masked);
self.secrets.insert(secret);
}
}
/// Add multiple secrets to be masked
pub fn add_secrets(&mut self, secrets: impl IntoIterator<Item = String>) {
for secret in secrets {
self.add_secret(secret);
}
}
/// Remove a secret from masking
pub fn remove_secret(&mut self, secret: &str) {
self.secrets.remove(secret);
self.secret_cache.remove(secret);
}
/// Clear all secrets
pub fn clear(&mut self) {
self.secrets.clear();
self.secret_cache.clear();
}
/// Mask secrets in the given text
pub fn mask(&self, text: &str) -> String {
let mut result = text.to_string();
// Use cached masked versions for better performance
for secret in &self.secrets {
if !secret.is_empty() {
if let Some(masked) = self.secret_cache.get(secret) {
result = result.replace(secret, masked);
}
}
}
// Also mask potential tokens and keys with regex patterns
result = self.mask_patterns(&result);
result
}
/// Create a mask for a secret, preserving some structure for debugging
fn create_mask(&self, secret: &str) -> String {
let len = secret.len();
if len <= 3 {
// Very short secrets - mask completely
self.mask_char.to_string().repeat(3)
} else if len <= 8 {
// Short secrets - show first character
format!(
"{}{}",
secret.chars().next().unwrap(),
self.mask_char.to_string().repeat(len - 1)
)
} else {
// Longer secrets - show first 2 and last 2 characters
let chars: Vec<char> = secret.chars().collect();
let first_two = chars.iter().take(2).collect::<String>();
let last_two = chars.iter().skip(len - 2).collect::<String>();
let middle_mask = self.mask_char.to_string().repeat(len - 4);
format!("{}{}{}", first_two, middle_mask, last_two)
}
}
/// Mask common patterns that look like secrets
fn mask_patterns(&self, text: &str) -> String {
let patterns = PATTERNS.get_or_init(CompiledPatterns::new);
let mut result = text.to_string();
// GitHub Personal Access Tokens
result = patterns.github_pat.replace_all(&result, "ghp_***").to_string();
// GitHub App tokens
result = patterns.github_app.replace_all(&result, "ghs_***").to_string();
// GitHub OAuth tokens
result = patterns.github_oauth.replace_all(&result, "gho_***").to_string();
// AWS Access Key IDs
result = patterns.aws_access_key.replace_all(&result, "AKIA***").to_string();
// AWS Secret Access Keys (basic pattern)
// Only mask if it's clearly in a secret context (basic heuristic)
if text.to_lowercase().contains("secret") || text.to_lowercase().contains("key") {
result = patterns.aws_secret.replace_all(&result, "***").to_string();
}
// JWT tokens (basic pattern)
result = patterns.jwt.replace_all(&result, "eyJ***.eyJ***.***").to_string();
// API keys with common prefixes
result = patterns.api_key.replace_all(&result, "${1}=***").to_string();
result
}
/// Check if text contains any secrets
pub fn contains_secrets(&self, text: &str) -> bool {
for secret in &self.secrets {
if text.contains(secret) {
return true;
}
}
// Also check for common patterns
self.has_secret_patterns(text)
}
/// Check if text contains common secret patterns
fn has_secret_patterns(&self, text: &str) -> bool {
let patterns = PATTERNS.get_or_init(CompiledPatterns::new);
patterns.github_pat.is_match(text) ||
patterns.github_app.is_match(text) ||
patterns.github_oauth.is_match(text) ||
patterns.aws_access_key.is_match(text) ||
patterns.jwt.is_match(text)
}
/// Get the number of secrets being tracked
pub fn secret_count(&self) -> usize {
self.secrets.len()
}
/// Check if a specific secret is being tracked
pub fn has_secret(&self, secret: &str) -> bool {
self.secrets.contains(secret)
}
}
impl Default for SecretMasker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_masking() {
let mut masker = SecretMasker::new();
masker.add_secret("secret123");
masker.add_secret("password456");
let input = "The secret is secret123 and password is password456";
let masked = masker.mask(input);
assert!(!masked.contains("secret123"));
assert!(!masked.contains("password456"));
assert!(masked.contains("***"));
}
#[test]
fn test_preserve_structure() {
let mut masker = SecretMasker::new();
masker.add_secret("verylongsecretkey123");
let input = "Key: verylongsecretkey123";
let masked = masker.mask(input);
// Should preserve first 2 and last 2 characters
assert!(masked.contains("ve"));
assert!(masked.contains("23"));
assert!(masked.contains("***"));
assert!(!masked.contains("verylongsecretkey123"));
}
#[test]
fn test_github_token_patterns() {
let masker = SecretMasker::new();
let input = "Token: ghp_1234567890123456789012345678901234567890";
let masked = masker.mask(input);
assert!(!masked.contains("ghp_1234567890123456789012345678901234567890"));
assert!(masked.contains("ghp_***"));
}
#[test]
fn test_aws_access_key_patterns() {
let masker = SecretMasker::new();
let input = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE";
let masked = masker.mask(input);
assert!(!masked.contains("AKIAIOSFODNN7EXAMPLE"));
assert!(masked.contains("AKIA***"));
}
#[test]
fn test_jwt_token_patterns() {
let masker = SecretMasker::new();
let input = "JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
let masked = masker.mask(input);
assert!(masked.contains("eyJ***.eyJ***.***"));
assert!(!masked.contains("SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"));
}
#[test]
fn test_contains_secrets() {
let mut masker = SecretMasker::new();
masker.add_secret("secret123");
assert!(masker.contains_secrets("The secret is secret123"));
assert!(!masker.contains_secrets("No secrets here"));
assert!(masker.contains_secrets("Token: ghp_1234567890123456789012345678901234567890"));
}
#[test]
fn test_short_secrets() {
let mut masker = SecretMasker::new();
masker.add_secret("ab"); // Too short, should not be added
masker.add_secret("abc"); // Minimum length
assert_eq!(masker.secret_count(), 1);
assert!(!masker.has_secret("ab"));
assert!(masker.has_secret("abc"));
}
#[test]
fn test_custom_mask_char() {
let mut masker = SecretMasker::with_mask_char('X');
masker.add_secret("secret123");
let input = "The secret is secret123";
let masked = masker.mask(input);
assert!(masked.contains("XX"));
assert!(!masked.contains("**"));
}
#[test]
fn test_remove_secret() {
let mut masker = SecretMasker::new();
masker.add_secret("secret123");
masker.add_secret("password456");
assert_eq!(masker.secret_count(), 2);
masker.remove_secret("secret123");
assert_eq!(masker.secret_count(), 1);
assert!(!masker.has_secret("secret123"));
assert!(masker.has_secret("password456"));
}
#[test]
fn test_clear_secrets() {
let mut masker = SecretMasker::new();
masker.add_secret("secret123");
masker.add_secret("password456");
assert_eq!(masker.secret_count(), 2);
masker.clear();
assert_eq!(masker.secret_count(), 0);
}
}

View File

@@ -0,0 +1,142 @@
use crate::{validation::validate_secret_value, SecretError, SecretProvider, SecretResult, SecretValue};
use async_trait::async_trait;
use std::collections::HashMap;
/// Environment variable secret provider
pub struct EnvironmentProvider {
prefix: Option<String>,
}
impl EnvironmentProvider {
/// Create a new environment provider
pub fn new(prefix: Option<String>) -> Self {
Self { prefix }
}
}
impl Default for EnvironmentProvider {
fn default() -> Self {
Self::new(None)
}
}
impl EnvironmentProvider {
/// Get the full environment variable name
fn get_env_name(&self, name: &str) -> String {
match &self.prefix {
Some(prefix) => format!("{}{}", prefix, name),
None => name.to_string(),
}
}
}
#[async_trait]
impl SecretProvider for EnvironmentProvider {
async fn get_secret(&self, name: &str) -> SecretResult<SecretValue> {
let env_name = self.get_env_name(name);
match std::env::var(&env_name) {
Ok(value) => {
// Validate the secret value
validate_secret_value(&value)?;
let mut metadata = HashMap::new();
metadata.insert("source".to_string(), "environment".to_string());
metadata.insert("env_var".to_string(), env_name);
Ok(SecretValue::with_metadata(value, metadata))
}
Err(std::env::VarError::NotPresent) => Err(SecretError::not_found(name)),
Err(std::env::VarError::NotUnicode(_)) => Err(SecretError::InvalidFormat(format!(
"Environment variable '{}' contains invalid Unicode",
env_name
))),
}
}
async fn list_secrets(&self) -> SecretResult<Vec<String>> {
let mut secrets = Vec::new();
for (key, _) in std::env::vars() {
if let Some(prefix) = &self.prefix {
if key.starts_with(prefix) {
secrets.push(key[prefix.len()..].to_string());
}
} else {
// Without a prefix, we can't distinguish secrets from regular env vars
// So we'll return an error suggesting the use of a prefix
return Err(SecretError::internal(
"Cannot list secrets from environment without a prefix. Configure a prefix like 'WRKFLW_SECRET_'"
));
}
}
Ok(secrets)
}
fn name(&self) -> &str {
"environment"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_environment_provider_basic() {
let provider = EnvironmentProvider::default();
// Use unique secret name to avoid test conflicts
let test_secret_name = format!("TEST_SECRET_{}", std::process::id());
std::env::set_var(&test_secret_name, "test_value");
let result = provider.get_secret(&test_secret_name).await;
assert!(result.is_ok());
let secret = result.unwrap();
assert_eq!(secret.value(), "test_value");
assert_eq!(
secret.metadata.get("source"),
Some(&"environment".to_string())
);
// Clean up
std::env::remove_var(&test_secret_name);
}
#[tokio::test]
async fn test_environment_provider_with_prefix() {
let provider = EnvironmentProvider::new(Some("WRKFLW_SECRET_".to_string()));
// Use unique secret name to avoid test conflicts
let test_secret_name = format!("API_KEY_{}", std::process::id());
let full_env_name = format!("WRKFLW_SECRET_{}", test_secret_name);
std::env::set_var(&full_env_name, "secret_api_key");
let result = provider.get_secret(&test_secret_name).await;
assert!(result.is_ok());
let secret = result.unwrap();
assert_eq!(secret.value(), "secret_api_key");
// Clean up
std::env::remove_var(&full_env_name);
}
#[tokio::test]
async fn test_environment_provider_not_found() {
let provider = EnvironmentProvider::default();
let result = provider.get_secret("NONEXISTENT_SECRET").await;
assert!(result.is_err());
match result.unwrap_err() {
SecretError::NotFound { name } => {
assert_eq!(name, "NONEXISTENT_SECRET");
}
_ => panic!("Expected NotFound error"),
}
}
}

View File

@@ -0,0 +1,286 @@
use crate::{validation::validate_secret_value, SecretError, SecretProvider, SecretResult, SecretValue};
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
/// File-based secret provider
pub struct FileProvider {
path: String,
}
impl FileProvider {
/// Create a new file provider
pub fn new(path: impl Into<String>) -> Self {
Self { path: path.into() }
}
/// Expand tilde in path
fn expand_path(&self) -> String {
if self.path.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(&self.path[2..]).to_string_lossy().to_string();
}
}
self.path.clone()
}
/// Load secrets from JSON file
async fn load_json_secrets(&self, file_path: &Path) -> SecretResult<HashMap<String, String>> {
let content = tokio::fs::read_to_string(file_path).await?;
let json: Value = serde_json::from_str(&content)?;
let mut secrets = HashMap::new();
if let Value::Object(obj) = json {
for (key, value) in obj {
if let Value::String(secret_value) = value {
secrets.insert(key, secret_value);
} else {
secrets.insert(key, value.to_string());
}
}
}
Ok(secrets)
}
/// Load secrets from YAML file
async fn load_yaml_secrets(&self, file_path: &Path) -> SecretResult<HashMap<String, String>> {
let content = tokio::fs::read_to_string(file_path).await?;
let yaml: serde_yaml::Value = serde_yaml::from_str(&content)?;
let mut secrets = HashMap::new();
if let serde_yaml::Value::Mapping(map) = yaml {
for (key, value) in map {
if let (serde_yaml::Value::String(k), v) = (key, value) {
let secret_value = match v {
serde_yaml::Value::String(s) => s,
_ => serde_yaml::to_string(&v)?.trim().to_string(),
};
secrets.insert(k, secret_value);
}
}
}
Ok(secrets)
}
/// Load secrets from environment-style file
async fn load_env_secrets(&self, file_path: &Path) -> SecretResult<HashMap<String, String>> {
let content = tokio::fs::read_to_string(file_path).await?;
let mut secrets = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = value.trim();
// Handle quoted values
let value = if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
&value[1..value.len() - 1]
} else {
value
};
secrets.insert(key, value.to_string());
}
}
Ok(secrets)
}
/// Load all secrets from the configured path
async fn load_secrets(&self) -> SecretResult<HashMap<String, String>> {
let expanded_path = self.expand_path();
let path = Path::new(&expanded_path);
if !path.exists() {
return Ok(HashMap::new());
}
if path.is_file() {
// Single file - determine format by extension
if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
match extension.to_lowercase().as_str() {
"json" => self.load_json_secrets(path).await,
"yml" | "yaml" => self.load_yaml_secrets(path).await,
"env" => self.load_env_secrets(path).await,
_ => {
// Default to environment format for unknown extensions
self.load_env_secrets(path).await
}
}
} else {
// No extension, try environment format
self.load_env_secrets(path).await
}
} else {
// Directory - load from multiple files
let mut all_secrets = HashMap::new();
let mut entries = tokio::fs::read_dir(path).await?;
while let Some(entry) = entries.next_entry().await? {
let entry_path = entry.path();
if entry_path.is_file() {
if let Some(extension) = entry_path.extension().and_then(|ext| ext.to_str()) {
let secrets = match extension.to_lowercase().as_str() {
"json" => self.load_json_secrets(&entry_path).await?,
"yml" | "yaml" => self.load_yaml_secrets(&entry_path).await?,
"env" => self.load_env_secrets(&entry_path).await?,
_ => continue, // Skip unknown file types
};
all_secrets.extend(secrets);
}
}
}
Ok(all_secrets)
}
}
}
#[async_trait]
impl SecretProvider for FileProvider {
async fn get_secret(&self, name: &str) -> SecretResult<SecretValue> {
let secrets = self.load_secrets().await?;
if let Some(value) = secrets.get(name) {
// Validate the secret value
validate_secret_value(value)?;
let mut metadata = HashMap::new();
metadata.insert("source".to_string(), "file".to_string());
metadata.insert("file_path".to_string(), self.expand_path());
Ok(SecretValue::with_metadata(value.clone(), metadata))
} else {
Err(SecretError::not_found(name))
}
}
async fn list_secrets(&self) -> SecretResult<Vec<String>> {
let secrets = self.load_secrets().await?;
Ok(secrets.keys().cloned().collect())
}
fn name(&self) -> &str {
"file"
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn create_test_json_file(dir: &TempDir, content: &str) -> String {
let file_path = dir.path().join("secrets.json");
tokio::fs::write(&file_path, content).await.unwrap();
file_path.to_string_lossy().to_string()
}
async fn create_test_env_file(dir: &TempDir, content: &str) -> String {
let file_path = dir.path().join("secrets.env");
tokio::fs::write(&file_path, content).await.unwrap();
file_path.to_string_lossy().to_string()
}
#[tokio::test]
async fn test_file_provider_json() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_json_file(
&temp_dir,
r#"
{
"API_KEY": "secret_api_key",
"DB_PASSWORD": "secret_password"
}
"#,
)
.await;
let provider = FileProvider::new(file_path);
let result = provider.get_secret("API_KEY").await;
assert!(result.is_ok());
let secret = result.unwrap();
assert_eq!(secret.value(), "secret_api_key");
assert_eq!(secret.metadata.get("source"), Some(&"file".to_string()));
}
#[tokio::test]
async fn test_file_provider_env_format() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_env_file(
&temp_dir,
r#"
# This is a comment
API_KEY=secret_api_key
DB_PASSWORD="quoted password"
GITHUB_TOKEN='single quoted token'
"#,
)
.await;
let provider = FileProvider::new(file_path);
let api_key = provider.get_secret("API_KEY").await.unwrap();
assert_eq!(api_key.value(), "secret_api_key");
let password = provider.get_secret("DB_PASSWORD").await.unwrap();
assert_eq!(password.value(), "quoted password");
let token = provider.get_secret("GITHUB_TOKEN").await.unwrap();
assert_eq!(token.value(), "single quoted token");
}
#[tokio::test]
async fn test_file_provider_not_found() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_json_file(&temp_dir, "{}").await;
let provider = FileProvider::new(file_path);
let result = provider.get_secret("NONEXISTENT").await;
assert!(result.is_err());
match result.unwrap_err() {
SecretError::NotFound { name } => {
assert_eq!(name, "NONEXISTENT");
}
_ => panic!("Expected NotFound error"),
}
}
#[tokio::test]
async fn test_file_provider_list_secrets() {
let temp_dir = TempDir::new().unwrap();
let file_path = create_test_json_file(
&temp_dir,
r#"
{
"SECRET_1": "value1",
"SECRET_2": "value2",
"SECRET_3": "value3"
}
"#,
)
.await;
let provider = FileProvider::new(file_path);
let secrets = provider.list_secrets().await.unwrap();
assert_eq!(secrets.len(), 3);
assert!(secrets.contains(&"SECRET_1".to_string()));
assert!(secrets.contains(&"SECRET_2".to_string()));
assert!(secrets.contains(&"SECRET_3".to_string()));
}
}

View File

@@ -0,0 +1,91 @@
use crate::{SecretError, SecretResult};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub mod env;
pub mod file;
// Cloud provider modules are planned for future implementation
// #[cfg(feature = "vault-provider")]
// pub mod vault;
// #[cfg(feature = "aws-provider")]
// pub mod aws;
// #[cfg(feature = "azure-provider")]
// pub mod azure;
// #[cfg(feature = "gcp-provider")]
// pub mod gcp;
/// A secret value with metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretValue {
/// The actual secret value
value: String,
/// Optional metadata about the secret
pub metadata: HashMap<String, String>,
/// When this secret was retrieved (for caching)
pub retrieved_at: chrono::DateTime<chrono::Utc>,
}
impl SecretValue {
/// Create a new secret value
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
metadata: HashMap::new(),
retrieved_at: chrono::Utc::now(),
}
}
/// Create a new secret value with metadata
pub fn with_metadata(value: impl Into<String>, metadata: HashMap<String, String>) -> Self {
Self {
value: value.into(),
metadata,
retrieved_at: chrono::Utc::now(),
}
}
/// Get the secret value
pub fn value(&self) -> &str {
&self.value
}
/// Check if this secret has expired based on TTL
pub fn is_expired(&self, ttl_seconds: u64) -> bool {
let now = chrono::Utc::now();
let elapsed = now.signed_duration_since(self.retrieved_at);
elapsed.num_seconds() > ttl_seconds as i64
}
}
/// Trait for secret providers
#[async_trait]
pub trait SecretProvider: Send + Sync {
/// Get a secret by name
async fn get_secret(&self, name: &str) -> SecretResult<SecretValue>;
/// List available secrets (optional, for providers that support it)
async fn list_secrets(&self) -> SecretResult<Vec<String>> {
Err(SecretError::internal(
"list_secrets not supported by this provider",
))
}
/// Check if the provider is healthy/accessible
async fn health_check(&self) -> SecretResult<()> {
// Default implementation tries to get a non-existent secret
// If it returns NotFound, the provider is healthy
match self.get_secret("__health_check__").await {
Err(SecretError::NotFound { .. }) => Ok(()),
Err(e) => Err(e),
Ok(_) => Ok(()), // Surprisingly, the health check secret exists
}
}
/// Get the provider name
fn name(&self) -> &str;
}

View File

@@ -0,0 +1,244 @@
// Copyright 2024 wrkflw contributors
// SPDX-License-Identifier: MIT
//! Rate limiting for secret access operations
use crate::{SecretError, SecretResult};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
/// Rate limiter configuration
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
/// Maximum requests per time window
pub max_requests: u32,
/// Time window duration
pub window_duration: Duration,
/// Whether to enable rate limiting
pub enabled: bool,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_requests: 100,
window_duration: Duration::from_secs(60), // 1 minute
enabled: true,
}
}
}
/// Track requests for a specific key
#[derive(Debug)]
struct RequestTracker {
requests: Vec<Instant>,
first_request: Instant,
}
impl RequestTracker {
fn new() -> Self {
let now = Instant::now();
Self {
requests: Vec::new(),
first_request: now,
}
}
fn add_request(&mut self, now: Instant) {
if self.requests.is_empty() {
self.first_request = now;
}
self.requests.push(now);
}
fn cleanup_old_requests(&mut self, window_duration: Duration, now: Instant) {
let cutoff = now - window_duration;
self.requests.retain(|&req_time| req_time > cutoff);
if let Some(&first) = self.requests.first() {
self.first_request = first;
}
}
fn request_count(&self) -> usize {
self.requests.len()
}
}
/// Rate limiter for secret access operations
pub struct RateLimiter {
config: RateLimitConfig,
trackers: Arc<RwLock<HashMap<String, RequestTracker>>>,
}
impl RateLimiter {
/// Create a new rate limiter with the given configuration
pub fn new(config: RateLimitConfig) -> Self {
Self {
config,
trackers: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Check if a request should be allowed for the given key
pub async fn check_rate_limit(&self, key: &str) -> SecretResult<()> {
if !self.config.enabled {
return Ok(());
}
let now = Instant::now();
let mut trackers = self.trackers.write().await;
// Clean up old requests for existing tracker
if let Some(tracker) = trackers.get_mut(key) {
tracker.cleanup_old_requests(self.config.window_duration, now);
// Check if we're over the limit
if tracker.request_count() >= self.config.max_requests as usize {
let time_until_reset = self.config.window_duration - (now - tracker.first_request);
return Err(SecretError::RateLimitExceeded(format!(
"Rate limit exceeded. Try again in {} seconds",
time_until_reset.as_secs()
)));
}
// Add the current request
tracker.add_request(now);
} else {
// Create new tracker and add first request
let mut tracker = RequestTracker::new();
tracker.add_request(now);
trackers.insert(key.to_string(), tracker);
}
Ok(())
}
/// Reset rate limit for a specific key
pub async fn reset_rate_limit(&self, key: &str) {
let mut trackers = self.trackers.write().await;
trackers.remove(key);
}
/// Clear all rate limit data
pub async fn clear_all(&self) {
let mut trackers = self.trackers.write().await;
trackers.clear();
}
/// Get current request count for a key
pub async fn get_request_count(&self, key: &str) -> usize {
let trackers = self.trackers.read().await;
trackers.get(key).map(|t| t.request_count()).unwrap_or(0)
}
/// Get rate limit configuration
pub fn config(&self) -> &RateLimitConfig {
&self.config
}
}
impl Default for RateLimiter {
fn default() -> Self {
Self::new(RateLimitConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::time::Duration;
#[tokio::test]
async fn test_rate_limit_basic() {
let config = RateLimitConfig {
max_requests: 3,
window_duration: Duration::from_secs(1),
enabled: true,
};
let limiter = RateLimiter::new(config);
// First 3 requests should succeed
assert!(limiter.check_rate_limit("test_key").await.is_ok());
assert!(limiter.check_rate_limit("test_key").await.is_ok());
assert!(limiter.check_rate_limit("test_key").await.is_ok());
// 4th request should fail
assert!(limiter.check_rate_limit("test_key").await.is_err());
}
#[tokio::test]
async fn test_rate_limit_different_keys() {
let config = RateLimitConfig {
max_requests: 2,
window_duration: Duration::from_secs(1),
enabled: true,
};
let limiter = RateLimiter::new(config);
// Different keys should have separate limits
assert!(limiter.check_rate_limit("key1").await.is_ok());
assert!(limiter.check_rate_limit("key1").await.is_ok());
assert!(limiter.check_rate_limit("key2").await.is_ok());
assert!(limiter.check_rate_limit("key2").await.is_ok());
// Both keys should now be at their limit
assert!(limiter.check_rate_limit("key1").await.is_err());
assert!(limiter.check_rate_limit("key2").await.is_err());
}
#[tokio::test]
async fn test_rate_limit_reset() {
let config = RateLimitConfig {
max_requests: 1,
window_duration: Duration::from_secs(60), // Long window
enabled: true,
};
let limiter = RateLimiter::new(config);
// Use up the limit
assert!(limiter.check_rate_limit("test_key").await.is_ok());
assert!(limiter.check_rate_limit("test_key").await.is_err());
// Reset and try again
limiter.reset_rate_limit("test_key").await;
assert!(limiter.check_rate_limit("test_key").await.is_ok());
}
#[tokio::test]
async fn test_rate_limit_disabled() {
let config = RateLimitConfig {
max_requests: 1,
window_duration: Duration::from_secs(1),
enabled: false,
};
let limiter = RateLimiter::new(config);
// All requests should succeed when disabled
for _ in 0..10 {
assert!(limiter.check_rate_limit("test_key").await.is_ok());
}
}
#[tokio::test]
async fn test_get_request_count() {
let config = RateLimitConfig {
max_requests: 5,
window_duration: Duration::from_secs(1),
enabled: true,
};
let limiter = RateLimiter::new(config);
assert_eq!(limiter.get_request_count("test_key").await, 0);
limiter.check_rate_limit("test_key").await.unwrap();
assert_eq!(limiter.get_request_count("test_key").await, 1);
limiter.check_rate_limit("test_key").await.unwrap();
assert_eq!(limiter.get_request_count("test_key").await, 2);
}
}

View File

@@ -0,0 +1,351 @@
use crate::{SecretError, SecretResult};
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Encrypted secret storage for sensitive data at rest
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedSecretStore {
/// Encrypted secrets map (base64 encoded)
secrets: HashMap<String, String>,
/// Salt for key derivation (base64 encoded)
salt: String,
/// Nonce for encryption (base64 encoded)
nonce: String,
}
impl EncryptedSecretStore {
/// Create a new encrypted secret store with a random key
pub fn new() -> SecretResult<(Self, [u8; 32])> {
let key = Aes256Gcm::generate_key(&mut OsRng);
let salt = Self::generate_salt();
let nonce = Self::generate_nonce();
let store = Self {
secrets: HashMap::new(),
salt: general_purpose::STANDARD.encode(salt),
nonce: general_purpose::STANDARD.encode(nonce),
};
Ok((store, key.into()))
}
/// Create an encrypted secret store from existing data
pub fn from_data(secrets: HashMap<String, String>, salt: String, nonce: String) -> Self {
Self {
secrets,
salt,
nonce,
}
}
/// Add an encrypted secret
pub fn add_secret(&mut self, key: &[u8; 32], name: &str, value: &str) -> SecretResult<()> {
let encrypted = self.encrypt_value(key, value)?;
self.secrets.insert(name.to_string(), encrypted);
Ok(())
}
/// Get and decrypt a secret
pub fn get_secret(&self, key: &[u8; 32], name: &str) -> SecretResult<String> {
let encrypted = self
.secrets
.get(name)
.ok_or_else(|| SecretError::not_found(name))?;
self.decrypt_value(key, encrypted)
}
/// Remove a secret
pub fn remove_secret(&mut self, name: &str) -> bool {
self.secrets.remove(name).is_some()
}
/// List all secret names
pub fn list_secrets(&self) -> Vec<String> {
self.secrets.keys().cloned().collect()
}
/// Check if a secret exists
pub fn has_secret(&self, name: &str) -> bool {
self.secrets.contains_key(name)
}
/// Get the number of stored secrets
pub fn secret_count(&self) -> usize {
self.secrets.len()
}
/// Clear all secrets
pub fn clear(&mut self) {
self.secrets.clear();
}
/// Encrypt a value
fn encrypt_value(&self, key: &[u8; 32], value: &str) -> SecretResult<String> {
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let nonce_bytes = general_purpose::STANDARD
.decode(&self.nonce)
.map_err(|e| SecretError::EncryptionError(format!("Invalid nonce: {}", e)))?;
if nonce_bytes.len() != 12 {
return Err(SecretError::EncryptionError(
"Invalid nonce length".to_string(),
));
}
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, value.as_bytes())
.map_err(|e| SecretError::EncryptionError(format!("Encryption failed: {}", e)))?;
Ok(general_purpose::STANDARD.encode(&ciphertext))
}
/// Decrypt a value
fn decrypt_value(&self, key: &[u8; 32], encrypted: &str) -> SecretResult<String> {
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let nonce_bytes = general_purpose::STANDARD
.decode(&self.nonce)
.map_err(|e| SecretError::EncryptionError(format!("Invalid nonce: {}", e)))?;
if nonce_bytes.len() != 12 {
return Err(SecretError::EncryptionError(
"Invalid nonce length".to_string(),
));
}
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = general_purpose::STANDARD
.decode(encrypted)
.map_err(|e| SecretError::EncryptionError(format!("Invalid ciphertext: {}", e)))?;
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|e| SecretError::EncryptionError(format!("Decryption failed: {}", e)))?;
String::from_utf8(plaintext)
.map_err(|e| SecretError::EncryptionError(format!("Invalid UTF-8: {}", e)))
}
/// Generate a random salt
fn generate_salt() -> [u8; 32] {
let mut salt = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut salt);
salt
}
/// Generate a random nonce
fn generate_nonce() -> [u8; 12] {
let mut nonce = [0u8; 12];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce);
nonce
}
/// Serialize to JSON
pub fn to_json(&self) -> SecretResult<String> {
serde_json::to_string_pretty(self)
.map_err(|e| SecretError::internal(format!("Serialization failed: {}", e)))
}
/// Deserialize from JSON
pub fn from_json(json: &str) -> SecretResult<Self> {
serde_json::from_str(json)
.map_err(|e| SecretError::internal(format!("Deserialization failed: {}", e)))
}
/// Save to file
pub async fn save_to_file(&self, path: &str) -> SecretResult<()> {
let json = self.to_json()?;
tokio::fs::write(path, json)
.await
.map_err(SecretError::IoError)
}
/// Load from file
pub async fn load_from_file(path: &str) -> SecretResult<Self> {
let json = tokio::fs::read_to_string(path)
.await
.map_err(SecretError::IoError)?;
Self::from_json(&json)
}
}
impl Default for EncryptedSecretStore {
fn default() -> Self {
let (store, _) = Self::new().expect("Failed to create default encrypted store");
store
}
}
/// Key derivation utilities
pub struct KeyDerivation;
impl KeyDerivation {
/// Derive a key from a password using PBKDF2
pub fn derive_key_from_password(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
let mut key = [0u8; 32];
let _ = pbkdf2::pbkdf2::<hmac::Hmac<sha2::Sha256>>(
password.as_bytes(),
salt,
iterations,
&mut key,
);
key
}
/// Generate a secure random key
pub fn generate_random_key() -> [u8; 32] {
Aes256Gcm::generate_key(&mut OsRng).into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_encrypted_secret_store_basic() {
let (mut store, key) = EncryptedSecretStore::new().unwrap();
// Add a secret
store
.add_secret(&key, "test_secret", "secret_value")
.unwrap();
// Retrieve the secret
let value = store.get_secret(&key, "test_secret").unwrap();
assert_eq!(value, "secret_value");
// Check metadata
assert!(store.has_secret("test_secret"));
assert_eq!(store.secret_count(), 1);
let secrets = store.list_secrets();
assert_eq!(secrets.len(), 1);
assert!(secrets.contains(&"test_secret".to_string()));
}
#[tokio::test]
async fn test_encrypted_secret_store_multiple_secrets() {
let (mut store, key) = EncryptedSecretStore::new().unwrap();
// Add multiple secrets
store.add_secret(&key, "secret1", "value1").unwrap();
store.add_secret(&key, "secret2", "value2").unwrap();
store.add_secret(&key, "secret3", "value3").unwrap();
// Retrieve all secrets
assert_eq!(store.get_secret(&key, "secret1").unwrap(), "value1");
assert_eq!(store.get_secret(&key, "secret2").unwrap(), "value2");
assert_eq!(store.get_secret(&key, "secret3").unwrap(), "value3");
assert_eq!(store.secret_count(), 3);
}
#[tokio::test]
async fn test_encrypted_secret_store_wrong_key() {
let (mut store, key1) = EncryptedSecretStore::new().unwrap();
let (_, key2) = EncryptedSecretStore::new().unwrap();
// Add secret with key1
store
.add_secret(&key1, "test_secret", "secret_value")
.unwrap();
// Try to retrieve with wrong key
let result = store.get_secret(&key2, "test_secret");
assert!(result.is_err());
}
#[tokio::test]
async fn test_encrypted_secret_store_not_found() {
let (store, key) = EncryptedSecretStore::new().unwrap();
let result = store.get_secret(&key, "nonexistent");
assert!(result.is_err());
match result.unwrap_err() {
SecretError::NotFound { name } => {
assert_eq!(name, "nonexistent");
}
_ => panic!("Expected NotFound error"),
}
}
#[tokio::test]
async fn test_encrypted_secret_store_remove() {
let (mut store, key) = EncryptedSecretStore::new().unwrap();
store
.add_secret(&key, "test_secret", "secret_value")
.unwrap();
assert!(store.has_secret("test_secret"));
let removed = store.remove_secret("test_secret");
assert!(removed);
assert!(!store.has_secret("test_secret"));
let removed_again = store.remove_secret("test_secret");
assert!(!removed_again);
}
#[tokio::test]
async fn test_encrypted_secret_store_serialization() {
let (mut store, key) = EncryptedSecretStore::new().unwrap();
store.add_secret(&key, "secret1", "value1").unwrap();
store.add_secret(&key, "secret2", "value2").unwrap();
// Serialize to JSON
let json = store.to_json().unwrap();
// Deserialize from JSON
let restored_store = EncryptedSecretStore::from_json(&json).unwrap();
// Verify secrets are still accessible
assert_eq!(
restored_store.get_secret(&key, "secret1").unwrap(),
"value1"
);
assert_eq!(
restored_store.get_secret(&key, "secret2").unwrap(),
"value2"
);
}
#[test]
fn test_key_derivation() {
let password = "test_password";
let salt = b"test_salt_bytes_32_chars_long!!";
let iterations = 10000;
let key1 = KeyDerivation::derive_key_from_password(password, salt, iterations);
let key2 = KeyDerivation::derive_key_from_password(password, salt, iterations);
// Same password and salt should produce same key
assert_eq!(key1, key2);
// Different salt should produce different key
let different_salt = b"different_salt_bytes_32_chars!";
let key3 = KeyDerivation::derive_key_from_password(password, different_salt, iterations);
assert_ne!(key1, key3);
}
#[test]
fn test_random_key_generation() {
let key1 = KeyDerivation::generate_random_key();
let key2 = KeyDerivation::generate_random_key();
// Random keys should be different
assert_ne!(key1, key2);
// Keys should be 32 bytes
assert_eq!(key1.len(), 32);
assert_eq!(key2.len(), 32);
}
}

View File

@@ -0,0 +1,249 @@
use crate::{SecretManager, SecretResult};
use regex::Regex;
use std::collections::HashMap;
lazy_static::lazy_static! {
/// Regex to match GitHub-style secret references: ${{ secrets.SECRET_NAME }}
static ref SECRET_PATTERN: Regex = Regex::new(
r"\$\{\{\s*secrets\.([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*\}\}"
).unwrap();
/// Regex to match provider-specific secret references: ${{ secrets.provider:SECRET_NAME }}
static ref PROVIDER_SECRET_PATTERN: Regex = Regex::new(
r"\$\{\{\s*secrets\.([a-zA-Z0-9_][a-zA-Z0-9_-]*):([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*\}\}"
).unwrap();
}
/// Secret substitution engine for replacing secret references in text
pub struct SecretSubstitution<'a> {
manager: &'a SecretManager,
resolved_secrets: HashMap<String, String>,
}
impl<'a> SecretSubstitution<'a> {
/// Create a new secret substitution engine
pub fn new(manager: &'a SecretManager) -> Self {
Self {
manager,
resolved_secrets: HashMap::new(),
}
}
/// Substitute all secret references in the given text
pub async fn substitute(&mut self, text: &str) -> SecretResult<String> {
let mut result = text.to_string();
// First, handle provider-specific secrets: ${{ secrets.provider:SECRET_NAME }}
result = self.substitute_provider_secrets(&result).await?;
// Then handle default provider secrets: ${{ secrets.SECRET_NAME }}
result = self.substitute_default_secrets(&result).await?;
Ok(result)
}
/// Substitute provider-specific secret references
async fn substitute_provider_secrets(&mut self, text: &str) -> SecretResult<String> {
let mut result = text.to_string();
for captures in PROVIDER_SECRET_PATTERN.captures_iter(text) {
let full_match = captures.get(0).unwrap().as_str();
let provider = captures.get(1).unwrap().as_str();
let secret_name = captures.get(2).unwrap().as_str();
let cache_key = format!("{}:{}", provider, secret_name);
let secret_value = if let Some(cached) = self.resolved_secrets.get(&cache_key) {
cached.clone()
} else {
let secret = self
.manager
.get_secret_from_provider(provider, secret_name)
.await?;
let value = secret.value().to_string();
self.resolved_secrets.insert(cache_key, value.clone());
value
};
result = result.replace(full_match, &secret_value);
}
Ok(result)
}
/// Substitute default provider secret references
async fn substitute_default_secrets(&mut self, text: &str) -> SecretResult<String> {
let mut result = text.to_string();
for captures in SECRET_PATTERN.captures_iter(text) {
let full_match = captures.get(0).unwrap().as_str();
let secret_name = captures.get(1).unwrap().as_str();
let secret_value = if let Some(cached) = self.resolved_secrets.get(secret_name) {
cached.clone()
} else {
let secret = self.manager.get_secret(secret_name).await?;
let value = secret.value().to_string();
self.resolved_secrets
.insert(secret_name.to_string(), value.clone());
value
};
result = result.replace(full_match, &secret_value);
}
Ok(result)
}
/// Get all resolved secrets (for masking purposes)
pub fn resolved_secrets(&self) -> &HashMap<String, String> {
&self.resolved_secrets
}
/// Check if text contains secret references
pub fn contains_secrets(text: &str) -> bool {
SECRET_PATTERN.is_match(text) || PROVIDER_SECRET_PATTERN.is_match(text)
}
/// Extract all secret references from text without resolving them
pub fn extract_secret_refs(text: &str) -> Vec<SecretRef> {
let mut refs = Vec::new();
// Extract provider-specific references
for captures in PROVIDER_SECRET_PATTERN.captures_iter(text) {
let full_match = captures.get(0).unwrap().as_str();
let provider = captures.get(1).unwrap().as_str();
let name = captures.get(2).unwrap().as_str();
refs.push(SecretRef {
full_text: full_match.to_string(),
provider: Some(provider.to_string()),
name: name.to_string(),
});
}
// Extract default provider references
for captures in SECRET_PATTERN.captures_iter(text) {
let full_match = captures.get(0).unwrap().as_str();
let name = captures.get(1).unwrap().as_str();
refs.push(SecretRef {
full_text: full_match.to_string(),
provider: None,
name: name.to_string(),
});
}
refs
}
}
/// A reference to a secret found in text
#[derive(Debug, Clone, PartialEq)]
pub struct SecretRef {
/// The full text of the secret reference (e.g., "${{ secrets.API_KEY }}")
pub full_text: String,
/// The provider name, if specified
pub provider: Option<String>,
/// The secret name
pub name: String,
}
impl SecretRef {
/// Get the cache key for this secret reference
pub fn cache_key(&self) -> String {
match &self.provider {
Some(provider) => format!("{}:{}", provider, self.name),
None => self.name.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SecretError, SecretManager};
#[tokio::test]
async fn test_basic_secret_substitution() {
// Use unique secret names to avoid test conflicts
let github_token_name = format!("GITHUB_TOKEN_{}", std::process::id());
let api_key_name = format!("API_KEY_{}", std::process::id());
std::env::set_var(&github_token_name, "ghp_test_token");
std::env::set_var(&api_key_name, "secret_api_key");
let manager = SecretManager::default().await.unwrap();
let mut substitution = SecretSubstitution::new(&manager);
let input = format!("Token: ${{{{ secrets.{} }}}}, API: ${{{{ secrets.{} }}}}", github_token_name, api_key_name);
let result = substitution.substitute(&input).await.unwrap();
assert_eq!(result, "Token: ghp_test_token, API: secret_api_key");
std::env::remove_var(&github_token_name);
std::env::remove_var(&api_key_name);
}
#[tokio::test]
async fn test_provider_specific_substitution() {
// Use unique secret name to avoid test conflicts
let vault_secret_name = format!("VAULT_SECRET_{}", std::process::id());
std::env::set_var(&vault_secret_name, "vault_value");
let manager = SecretManager::default().await.unwrap();
let mut substitution = SecretSubstitution::new(&manager);
let input = format!("Value: ${{{{ secrets.env:{} }}}}", vault_secret_name);
let result = substitution.substitute(&input).await.unwrap();
assert_eq!(result, "Value: vault_value");
std::env::remove_var(&vault_secret_name);
}
#[tokio::test]
async fn test_extract_secret_refs() {
let input = "Token: ${{ secrets.GITHUB_TOKEN }}, Vault: ${{ secrets.vault:API_KEY }}";
let refs = SecretSubstitution::extract_secret_refs(input);
assert_eq!(refs.len(), 2);
let github_ref = &refs.iter().find(|r| r.name == "GITHUB_TOKEN").unwrap();
assert_eq!(github_ref.provider, None);
assert_eq!(github_ref.full_text, "${{ secrets.GITHUB_TOKEN }}");
let vault_ref = &refs.iter().find(|r| r.name == "API_KEY").unwrap();
assert_eq!(vault_ref.provider, Some("vault".to_string()));
assert_eq!(vault_ref.full_text, "${{ secrets.vault:API_KEY }}");
}
#[tokio::test]
async fn test_contains_secrets() {
assert!(SecretSubstitution::contains_secrets(
"${{ secrets.API_KEY }}"
));
assert!(SecretSubstitution::contains_secrets(
"${{ secrets.vault:SECRET }}"
));
assert!(!SecretSubstitution::contains_secrets("${{ matrix.os }}"));
assert!(!SecretSubstitution::contains_secrets("No secrets here"));
}
#[tokio::test]
async fn test_secret_substitution_error_handling() {
let manager = SecretManager::default().await.unwrap();
let mut substitution = SecretSubstitution::new(&manager);
let input = "Token: ${{ secrets.NONEXISTENT_SECRET }}";
let result = substitution.substitute(input).await;
assert!(result.is_err());
match result.unwrap_err() {
SecretError::NotFound { name } => {
assert_eq!(name, "NONEXISTENT_SECRET");
}
_ => panic!("Expected NotFound error"),
}
}
}

View File

@@ -0,0 +1,234 @@
// Copyright 2024 wrkflw contributors
// SPDX-License-Identifier: MIT
//! Input validation utilities for secrets management
use crate::{SecretError, SecretResult};
use regex::Regex;
/// Maximum allowed secret value size (1MB)
pub const MAX_SECRET_SIZE: usize = 1024 * 1024;
/// Maximum allowed secret name length
pub const MAX_SECRET_NAME_LENGTH: usize = 255;
lazy_static::lazy_static! {
/// Valid secret name pattern: alphanumeric, underscores, hyphens, dots
static ref SECRET_NAME_PATTERN: Regex = Regex::new(r"^[a-zA-Z0-9_.-]+$").unwrap();
}
/// Validate a secret name
pub fn validate_secret_name(name: &str) -> SecretResult<()> {
if name.is_empty() {
return Err(SecretError::InvalidSecretName {
reason: "Secret name cannot be empty".to_string(),
});
}
if name.len() > MAX_SECRET_NAME_LENGTH {
return Err(SecretError::InvalidSecretName {
reason: format!(
"Secret name too long: {} characters (max: {})",
name.len(),
MAX_SECRET_NAME_LENGTH
),
});
}
if !SECRET_NAME_PATTERN.is_match(name) {
return Err(SecretError::InvalidSecretName {
reason: "Secret name can only contain letters, numbers, underscores, hyphens, and dots".to_string(),
});
}
// Check for potentially dangerous patterns
if name.starts_with('.') || name.ends_with('.') {
return Err(SecretError::InvalidSecretName {
reason: "Secret name cannot start or end with a dot".to_string(),
});
}
if name.contains("..") {
return Err(SecretError::InvalidSecretName {
reason: "Secret name cannot contain consecutive dots".to_string(),
});
}
// Reserved names
let reserved_names = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved_names.contains(&name.to_uppercase().as_str()) {
return Err(SecretError::InvalidSecretName {
reason: format!("'{}' is a reserved name", name),
});
}
Ok(())
}
/// Validate a secret value
pub fn validate_secret_value(value: &str) -> SecretResult<()> {
let size = value.len();
if size > MAX_SECRET_SIZE {
return Err(SecretError::SecretTooLarge {
size,
max_size: MAX_SECRET_SIZE,
});
}
// Check for null bytes which could cause issues
if value.contains('\0') {
return Err(SecretError::InvalidFormat(
"Secret value cannot contain null bytes".to_string(),
));
}
Ok(())
}
/// Validate a provider name
pub fn validate_provider_name(name: &str) -> SecretResult<()> {
if name.is_empty() {
return Err(SecretError::InvalidConfig(
"Provider name cannot be empty".to_string(),
));
}
if name.len() > 64 {
return Err(SecretError::InvalidConfig(
format!("Provider name too long: {} characters (max: 64)", name.len()),
));
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return Err(SecretError::InvalidConfig(
"Provider name can only contain letters, numbers, underscores, and hyphens".to_string(),
));
}
Ok(())
}
/// Sanitize input for logging to prevent log injection attacks
pub fn sanitize_for_logging(input: &str) -> String {
input
.chars()
.map(|c| match c {
'\n' | '\r' | '\t' => ' ',
c if c.is_control() => '?',
c => c,
})
.collect()
}
/// Check if a string might be a secret based on common patterns
pub fn looks_like_secret(value: &str) -> bool {
if value.len() < 8 {
return false;
}
// Check for high entropy (random-looking strings)
let unique_chars: std::collections::HashSet<char> = value.chars().collect();
let entropy_ratio = unique_chars.len() as f64 / value.len() as f64;
if entropy_ratio > 0.6 && value.len() > 16 {
return true;
}
// Check for common secret patterns
let secret_patterns = [
r"^[A-Za-z0-9+/=]{40,}$", // Base64-like
r"^[a-fA-F0-9]{32,}$", // Hex strings
r"^[A-Z0-9]{20,}$", // All caps alphanumeric
r"^sk_[a-zA-Z0-9_-]+$", // Stripe-like keys
r"^pk_[a-zA-Z0-9_-]+$", // Public keys
r"^rk_[a-zA-Z0-9_-]+$", // Restricted keys
];
for pattern in &secret_patterns {
if let Ok(regex) = Regex::new(pattern) {
if regex.is_match(value) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_secret_name() {
// Valid names
assert!(validate_secret_name("API_KEY").is_ok());
assert!(validate_secret_name("database-password").is_ok());
assert!(validate_secret_name("service.token").is_ok());
assert!(validate_secret_name("GITHUB_TOKEN_123").is_ok());
// Invalid names
assert!(validate_secret_name("").is_err());
assert!(validate_secret_name("name with spaces").is_err());
assert!(validate_secret_name("name/with/slashes").is_err());
assert!(validate_secret_name(".hidden").is_err());
assert!(validate_secret_name("ending.").is_err());
assert!(validate_secret_name("double..dot").is_err());
assert!(validate_secret_name("CON").is_err());
assert!(validate_secret_name(&"a".repeat(300)).is_err());
}
#[test]
fn test_validate_secret_value() {
// Valid values
assert!(validate_secret_value("short_secret").is_ok());
assert!(validate_secret_value("").is_ok()); // Empty is allowed
assert!(validate_secret_value(&"a".repeat(1000)).is_ok());
// Invalid values
assert!(validate_secret_value(&"a".repeat(MAX_SECRET_SIZE + 1)).is_err());
assert!(validate_secret_value("secret\0with\0nulls").is_err());
}
#[test]
fn test_validate_provider_name() {
// Valid names
assert!(validate_provider_name("env").is_ok());
assert!(validate_provider_name("file").is_ok());
assert!(validate_provider_name("aws-secrets").is_ok());
assert!(validate_provider_name("vault_prod").is_ok());
// Invalid names
assert!(validate_provider_name("").is_err());
assert!(validate_provider_name("name with spaces").is_err());
assert!(validate_provider_name("name/with/slashes").is_err());
assert!(validate_provider_name(&"a".repeat(100)).is_err());
}
#[test]
fn test_sanitize_for_logging() {
assert_eq!(sanitize_for_logging("normal text"), "normal text");
assert_eq!(sanitize_for_logging("line\nbreak"), "line break");
assert_eq!(sanitize_for_logging("tab\there"), "tab here");
assert_eq!(sanitize_for_logging("carriage\rreturn"), "carriage return");
}
#[test]
fn test_looks_like_secret() {
// Should detect as secrets
assert!(looks_like_secret("sk_test_abcdefghijklmnop1234567890"));
assert!(looks_like_secret("abcdefghijklmnopqrstuvwxyz123456"));
assert!(looks_like_secret("ABCDEF1234567890ABCDEF1234567890"));
assert!(looks_like_secret("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODkw"));
// Should not detect as secrets
assert!(!looks_like_secret("short"));
assert!(!looks_like_secret("this_is_just_a_regular_variable_name"));
assert!(!looks_like_secret("hello world this is plain text"));
}
}

View File

@@ -0,0 +1,326 @@
// Copyright 2024 wrkflw contributors
// SPDX-License-Identifier: MIT
//! Integration tests for the secrets crate
use std::collections::HashMap;
use std::process;
use tempfile::TempDir;
use tokio;
use wrkflw_secrets::{
SecretConfig, SecretManager, SecretMasker, SecretProviderConfig, SecretSubstitution,
};
/// Test end-to-end secret management workflow
#[tokio::test]
async fn test_end_to_end_secret_workflow() {
// Create a temporary directory for file-based secrets
let temp_dir = TempDir::new().unwrap();
let secrets_file = temp_dir.path().join("secrets.json");
// Create a secrets file
let secrets_content = r#"
{
"database_password": "super_secret_db_pass_123",
"api_token": "tk_abc123def456ghi789",
"encryption_key": "key_zyxwvutsrqponmlkjihgfedcba9876543210"
}
"#;
std::fs::write(&secrets_file, secrets_content).unwrap();
// Set up environment variables
let env_secret_name = format!("GITHUB_TOKEN_{}", process::id());
std::env::set_var(&env_secret_name, "ghp_1234567890abcdefghijklmnopqrstuvwxyz");
// Create configuration
let mut providers = HashMap::new();
providers.insert(
"env".to_string(),
SecretProviderConfig::Environment { prefix: None },
);
providers.insert(
"file".to_string(),
SecretProviderConfig::File {
path: secrets_file.to_string_lossy().to_string(),
},
);
let config = SecretConfig {
default_provider: "env".to_string(),
providers,
enable_masking: true,
timeout_seconds: 30,
enable_caching: true,
cache_ttl_seconds: 300,
rate_limit: Default::default(),
};
// Initialize secret manager
let manager = SecretManager::new(config).await.unwrap();
// Test 1: Get secret from environment provider
let env_secret = manager.get_secret(&env_secret_name).await.unwrap();
assert_eq!(env_secret.value(), "ghp_1234567890abcdefghijklmnopqrstuvwxyz");
assert_eq!(env_secret.metadata.get("source"), Some(&"environment".to_string()));
// Test 2: Get secret from file provider
let file_secret = manager
.get_secret_from_provider("file", "database_password")
.await
.unwrap();
assert_eq!(file_secret.value(), "super_secret_db_pass_123");
assert_eq!(file_secret.metadata.get("source"), Some(&"file".to_string()));
// Test 3: List secrets from file provider
let all_secrets = manager.list_all_secrets().await.unwrap();
assert!(all_secrets.contains_key("file"));
let file_secrets = &all_secrets["file"];
assert!(file_secrets.contains(&"database_password".to_string()));
assert!(file_secrets.contains(&"api_token".to_string()));
assert!(file_secrets.contains(&"encryption_key".to_string()));
// Test 4: Secret substitution
let mut substitution = SecretSubstitution::new(&manager);
let input = format!(
"Database: ${{{{ secrets.file:database_password }}}}, GitHub: ${{{{ secrets.{} }}}}",
env_secret_name
);
let output = substitution.substitute(&input).await.unwrap();
assert!(output.contains("super_secret_db_pass_123"));
assert!(output.contains("ghp_1234567890abcdefghijklmnopqrstuvwxyz"));
// Test 5: Secret masking
let mut masker = SecretMasker::new();
masker.add_secret("super_secret_db_pass_123");
masker.add_secret("ghp_1234567890abcdefghijklmnopqrstuvwxyz");
let log_message = "Connection failed: super_secret_db_pass_123 invalid for ghp_1234567890abcdefghijklmnopqrstuvwxyz";
let masked = masker.mask(log_message);
assert!(!masked.contains("super_secret_db_pass_123"));
assert!(!masked.contains("ghp_1234567890abcdefghijklmnopqrstuvwxyz"));
assert!(masked.contains("***"));
// Test 6: Health check
let health_results = manager.health_check().await;
assert!(health_results.get("env").unwrap().is_ok());
assert!(health_results.get("file").unwrap().is_ok());
// Test 7: Caching behavior
let start_time = std::time::Instant::now();
let _ = manager.get_secret(&env_secret_name).await.unwrap();
let first_duration = start_time.elapsed();
let start_time = std::time::Instant::now();
let _ = manager.get_secret(&env_secret_name).await.unwrap();
let second_duration = start_time.elapsed();
// Second call should be faster due to caching
assert!(second_duration < first_duration);
// Cleanup
std::env::remove_var(&env_secret_name);
}
/// Test error handling scenarios
#[tokio::test]
async fn test_error_handling() {
let manager = SecretManager::default().await.unwrap();
// Test 1: Secret not found
let result = manager.get_secret("NONEXISTENT_SECRET_12345").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
// Test 2: Invalid provider
let result = manager
.get_secret_from_provider("invalid_provider", "some_secret")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
// Test 3: Invalid secret name
let result = manager.get_secret("").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
// Test 4: Invalid secret name with special characters
let result = manager.get_secret("invalid/secret/name").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("can only contain"));
}
/// Test rate limiting functionality
#[tokio::test]
async fn test_rate_limiting() {
use wrkflw_secrets::rate_limit::RateLimitConfig;
use std::time::Duration;
// Create config with very low rate limit
let mut config = SecretConfig::default();
config.rate_limit = RateLimitConfig {
max_requests: 2,
window_duration: Duration::from_secs(10),
enabled: true,
};
let manager = SecretManager::new(config).await.unwrap();
// Set up test secret
let test_secret_name = format!("RATE_LIMIT_TEST_{}", process::id());
std::env::set_var(&test_secret_name, "test_value");
// First two requests should succeed
let result1 = manager.get_secret(&test_secret_name).await;
assert!(result1.is_ok());
let result2 = manager.get_secret(&test_secret_name).await;
assert!(result2.is_ok());
// Third request should fail due to rate limiting
let result3 = manager.get_secret(&test_secret_name).await;
assert!(result3.is_err());
assert!(result3.unwrap_err().to_string().contains("Rate limit exceeded"));
// Cleanup
std::env::remove_var(&test_secret_name);
}
/// Test concurrent access patterns
#[tokio::test]
async fn test_concurrent_access() {
use std::sync::Arc;
let manager = Arc::new(SecretManager::default().await.unwrap());
// Set up test secret
let test_secret_name = format!("CONCURRENT_TEST_{}", process::id());
std::env::set_var(&test_secret_name, "concurrent_test_value");
// Spawn multiple concurrent tasks
let mut handles = Vec::new();
for i in 0..10 {
let manager_clone = Arc::clone(&manager);
let secret_name = test_secret_name.clone();
let handle = tokio::spawn(async move {
let result = manager_clone.get_secret(&secret_name).await;
(i, result)
});
handles.push(handle);
}
// Wait for all tasks to complete
let mut successful_requests = 0;
for handle in handles {
let (_, result) = handle.await.unwrap();
if result.is_ok() {
successful_requests += 1;
assert_eq!(result.unwrap().value(), "concurrent_test_value");
}
}
// At least some requests should succeed (depending on rate limiting)
assert!(successful_requests > 0);
// Cleanup
std::env::remove_var(&test_secret_name);
}
/// Test secret substitution edge cases
#[tokio::test]
async fn test_substitution_edge_cases() {
let manager = SecretManager::default().await.unwrap();
// Set up test secrets
let secret1_name = format!("EDGE_CASE_1_{}", process::id());
let secret2_name = format!("EDGE_CASE_2_{}", process::id());
std::env::set_var(&secret1_name, "value1");
std::env::set_var(&secret2_name, "value2");
let mut substitution = SecretSubstitution::new(&manager);
// Test 1: Multiple references to the same secret
let input = format!(
"First: ${{{{ secrets.{} }}}} Second: ${{{{ secrets.{} }}}}",
secret1_name, secret1_name
);
let output = substitution.substitute(&input).await.unwrap();
assert_eq!(output, "First: value1 Second: value1");
// Test 2: Nested-like patterns (should not be substituted)
let input = "This is not a secret: ${ secrets.FAKE }";
let output = substitution.substitute(&input).await.unwrap();
assert_eq!(input, output); // Should remain unchanged
// Test 3: Mixed valid and invalid references
let input = format!(
"Valid: ${{{{ secrets.{} }}}} Invalid: ${{{{ secrets.NONEXISTENT }}}}",
secret1_name
);
let result = substitution.substitute(&input).await;
assert!(result.is_err()); // Should fail due to missing secret
// Test 4: Empty input
let output = substitution.substitute("").await.unwrap();
assert_eq!(output, "");
// Test 5: No secret references
let input = "This is just plain text with no secrets";
let output = substitution.substitute(input).await.unwrap();
assert_eq!(input, output);
// Cleanup
std::env::remove_var(&secret1_name);
std::env::remove_var(&secret2_name);
}
/// Test masking comprehensive patterns
#[tokio::test]
async fn test_comprehensive_masking() {
let mut masker = SecretMasker::new();
// Add various types of secrets
masker.add_secret("password123");
masker.add_secret("api_key_abcdef123456");
masker.add_secret("very_long_secret_key_that_should_preserve_structure_987654321");
// Test various input scenarios
let test_cases = vec![
(
"Password is password123 and API key is api_key_abcdef123456",
vec!["password123", "api_key_abcdef123456"],
),
(
"GitHub token: ghp_1234567890123456789012345678901234567890",
vec!["ghp_"],
),
(
"AWS key: AKIAIOSFODNN7EXAMPLE",
vec!["AKIA"],
),
(
"JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
vec!["eyJ", "***"],
),
];
for (input, should_not_contain) in test_cases {
let masked = masker.mask(input);
for pattern in should_not_contain {
if pattern != "***" {
assert!(
!masked.contains(pattern) || pattern == "ghp_" || pattern == "AKIA" || pattern == "eyJ",
"Masked text '{}' should not contain '{}' (or only partial patterns)",
masked,
pattern
);
} else {
assert!(
masked.contains(pattern),
"Masked text '{}' should contain '{}'",
masked,
pattern
);
}
}
}
}

View File

@@ -141,6 +141,7 @@ pub async fn execute_workflow_cli(
runtime_type, runtime_type,
verbose, verbose,
preserve_containers_on_failure: false, // Default for this path preserve_containers_on_failure: false, // Default for this path
secrets_config: None, // Use default secrets configuration
}; };
match wrkflw_executor::execute_workflow(path, config).await { match wrkflw_executor::execute_workflow(path, config).await {
@@ -533,6 +534,7 @@ pub fn start_next_workflow_execution(
runtime_type, runtime_type,
verbose, verbose,
preserve_containers_on_failure, preserve_containers_on_failure,
secrets_config: None, // Use default secrets configuration
}; };
let execution_result = wrkflw_utils::fd::with_stderr_to_null(|| { let execution_result = wrkflw_utils::fd::with_stderr_to_null(|| {

View File

@@ -403,6 +403,7 @@ async fn main() {
runtime_type: runtime.clone().into(), runtime_type: runtime.clone().into(),
verbose, verbose,
preserve_containers_on_failure: *preserve_containers_on_failure, preserve_containers_on_failure: *preserve_containers_on_failure,
secrets_config: None, // Use default secrets configuration
}; };
// Check if we're explicitly or implicitly running a GitLab pipeline // Check if we're explicitly or implicitly running a GitLab pipeline

View File

@@ -0,0 +1,65 @@
# wrkflw Secrets Configuration
# This file demonstrates various secret provider configurations
# Default provider to use when no provider is specified in ${{ secrets.name }}
default_provider: env
# Enable automatic masking of secrets in logs and output
enable_masking: true
# Timeout for secret operations (seconds)
timeout_seconds: 30
# Enable caching for performance
enable_caching: true
# Cache TTL in seconds
cache_ttl_seconds: 300
# Secret provider configurations
providers:
# Environment variable provider
env:
type: environment
# Optional prefix for environment variables
# If specified, looks for WRKFLW_SECRET_* variables
# prefix: "WRKFLW_SECRET_"
# File-based secret storage
file:
type: file
# Path to secrets file (supports JSON, YAML, or environment format)
path: "~/.wrkflw/secrets.json"
# HashiCorp Vault (requires vault-provider feature)
vault:
type: vault
url: "https://vault.example.com"
auth:
method: token
token: "${VAULT_TOKEN}"
mount_path: "secret"
# AWS Secrets Manager (requires aws-provider feature)
aws:
type: aws_secrets_manager
region: "us-east-1"
# Optional role to assume for cross-account access
role_arn: "arn:aws:iam::123456789012:role/SecretRole"
# Azure Key Vault (requires azure-provider feature)
azure:
type: azure_key_vault
vault_url: "https://myvault.vault.azure.net/"
auth:
method: service_principal
client_id: "${AZURE_CLIENT_ID}"
client_secret: "${AZURE_CLIENT_SECRET}"
tenant_id: "${AZURE_TENANT_ID}"
# Google Cloud Secret Manager (requires gcp-provider feature)
gcp:
type: gcp_secret_manager
project_id: "my-project-id"
# Optional service account key file
key_file: "/path/to/service-account.json"

View File

@@ -0,0 +1,505 @@
# wrkflw Secrets Management Demo
This demo demonstrates the comprehensive secrets management system in wrkflw, addressing the critical need for secure secret handling in CI/CD workflows.
## The Problem
Without proper secrets support, workflows are severely limited because:
1. **No way to access sensitive data** - API keys, tokens, passwords, certificates
2. **Security risks** - Hardcoded secrets in code or plain text in logs
3. **Limited usefulness** - Can't integrate with real services that require authentication
4. **Compliance issues** - Unable to meet security standards for production workflows
## The Solution
wrkflw now provides comprehensive secrets management with:
- **Multiple secret providers** (environment variables, files, HashiCorp Vault, AWS Secrets Manager, etc.)
- **GitHub Actions-compatible syntax** (`${{ secrets.* }}`)
- **Automatic secret masking** in logs and output
- **Encrypted storage** for sensitive environments
- **Flexible configuration** for different deployment scenarios
## Quick Start
### 1. Environment Variables (Simplest)
```bash
# Set secrets as environment variables
export GITHUB_TOKEN="ghp_your_token_here"
export API_KEY="your_api_key"
export DB_PASSWORD="secure_password"
```
Create a workflow that uses secrets:
```yaml
# .github/workflows/secrets-demo.yml
name: Secrets Demo
on: [push]
jobs:
test-secrets:
runs-on: ubuntu-latest
steps:
- name: Use GitHub Token
run: |
echo "Using token to access GitHub API"
curl -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/user
- name: Use API Key
run: |
echo "API Key: ${{ secrets.API_KEY }}"
- name: Database Connection
env:
DB_PASS: ${{ secrets.DB_PASSWORD }}
run: |
echo "Connecting to database with password: ${DB_PASS}"
```
Run with wrkflw:
```bash
wrkflw run .github/workflows/secrets-demo.yml
```
### 2. File-based Secrets
Create a secrets file:
```json
{
"API_KEY": "your_api_key_here",
"DB_PASSWORD": "secure_database_password",
"GITHUB_TOKEN": "ghp_your_github_token"
}
```
Or environment file format:
```bash
# secrets.env
API_KEY=your_api_key_here
DB_PASSWORD="secure database password"
GITHUB_TOKEN=ghp_your_github_token
```
Configure wrkflw to use file-based secrets:
```yaml
# ~/.wrkflw/secrets.yml
default_provider: file
enable_masking: true
timeout_seconds: 30
providers:
file:
type: file
path: "./secrets.json" # or "./secrets.env"
```
### 3. Advanced Configuration
For production environments, use external secret managers:
```yaml
# ~/.wrkflw/secrets.yml
default_provider: vault
enable_masking: true
timeout_seconds: 30
enable_caching: true
cache_ttl_seconds: 300
providers:
env:
type: environment
prefix: "WRKFLW_SECRET_"
vault:
type: vault
url: "https://vault.company.com"
auth:
method: token
token: "${VAULT_TOKEN}"
mount_path: "secret"
aws:
type: aws_secrets_manager
region: "us-east-1"
role_arn: "arn:aws:iam::123456789012:role/SecretRole"
```
## Secret Providers
### Environment Variables
**Best for**: Development and simple deployments
```bash
# With prefix
export WRKFLW_SECRET_API_KEY="your_key"
export WRKFLW_SECRET_DB_PASSWORD="password"
# Direct environment variables
export GITHUB_TOKEN="ghp_token"
export API_KEY="key_value"
```
Use in workflows:
```yaml
steps:
- name: Use prefixed secret
run: echo "API: ${{ secrets.env:API_KEY }}"
- name: Use direct secret
run: echo "Token: ${{ secrets.GITHUB_TOKEN }}"
```
### File-based Storage
**Best for**: Local development and testing
Supports multiple formats:
**JSON** (`secrets.json`):
```json
{
"GITHUB_TOKEN": "ghp_your_token",
"API_KEY": "your_api_key",
"DATABASE_URL": "postgresql://user:pass@localhost/db"
}
```
**YAML** (`secrets.yml`):
```yaml
GITHUB_TOKEN: ghp_your_token
API_KEY: your_api_key
DATABASE_URL: postgresql://user:pass@localhost/db
```
**Environment** (`secrets.env`):
```bash
GITHUB_TOKEN=ghp_your_token
API_KEY=your_api_key
DATABASE_URL="postgresql://user:pass@localhost/db"
```
### HashiCorp Vault
**Best for**: Production environments with centralized secret management
```yaml
providers:
vault:
type: vault
url: "https://vault.company.com"
auth:
method: token
token: "${VAULT_TOKEN}"
mount_path: "secret/v2"
```
Use vault secrets in workflows:
```yaml
steps:
- name: Use Vault secret
run: curl -H "X-API-Key: ${{ secrets.vault:api-key }}" api.service.com
```
### AWS Secrets Manager
**Best for**: AWS-native deployments
```yaml
providers:
aws:
type: aws_secrets_manager
region: "us-east-1"
role_arn: "arn:aws:iam::123456789012:role/SecretRole"
```
### Azure Key Vault
**Best for**: Azure-native deployments
```yaml
providers:
azure:
type: azure_key_vault
vault_url: "https://myvault.vault.azure.net/"
auth:
method: service_principal
client_id: "${AZURE_CLIENT_ID}"
client_secret: "${AZURE_CLIENT_SECRET}"
tenant_id: "${AZURE_TENANT_ID}"
```
## Secret Masking
wrkflw automatically masks secrets in logs to prevent accidental exposure:
```bash
# Original log:
# "API response: {\"token\": \"ghp_1234567890abcdef\", \"status\": \"ok\"}"
# Masked log:
# "API response: {\"token\": \"ghp_***\", \"status\": \"ok\"}"
```
Automatically detects and masks:
- GitHub Personal Access Tokens (`ghp_*`)
- GitHub App tokens (`ghs_*`)
- GitHub OAuth tokens (`gho_*`)
- AWS Access Keys (`AKIA*`)
- JWT tokens
- Generic API keys
## Workflow Examples
### GitHub API Integration
```yaml
name: GitHub API Demo
on: [push]
jobs:
github-integration:
runs-on: ubuntu-latest
steps:
- name: List repositories
run: |
curl -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/user/repos
- name: Create issue
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/owner/repo/issues \
-d '{"title":"Automated issue","body":"Created by wrkflw"}'
```
### Database Operations
```yaml
name: Database Demo
on: [push]
jobs:
database-ops:
runs-on: ubuntu-latest
steps:
- name: Run migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: |
echo "Running database migrations..."
# Your migration commands here
- name: Backup database
run: |
pg_dump "${{ secrets.DATABASE_URL }}" > backup.sql
```
### Multi-Provider Example
```yaml
name: Multi-Provider Demo
on: [push]
jobs:
multi-secrets:
runs-on: ubuntu-latest
steps:
- name: Use environment secret
run: echo "Env: ${{ secrets.env:API_KEY }}"
- name: Use file secret
run: echo "File: ${{ secrets.file:GITHUB_TOKEN }}"
- name: Use Vault secret
run: echo "Vault: ${{ secrets.vault:database-password }}"
- name: Use AWS secret
run: echo "AWS: ${{ secrets.aws:prod/api/key }}"
```
## Security Best Practices
### 1. Use Appropriate Providers
- **Development**: Environment variables or files
- **Staging**: File-based or simple vault
- **Production**: External secret managers (Vault, AWS, Azure, GCP)
### 2. Enable Secret Masking
Always enable masking in production:
```yaml
enable_masking: true
```
### 3. Rotate Secrets Regularly
Use secret managers that support automatic rotation:
```yaml
providers:
aws:
type: aws_secrets_manager
region: "us-east-1"
# AWS Secrets Manager handles automatic rotation
```
### 4. Use Least Privilege
Grant minimal necessary permissions:
```yaml
providers:
vault:
type: vault
auth:
method: app_role
role_id: "${VAULT_ROLE_ID}"
secret_id: "${VAULT_SECRET_ID}"
# Role has access only to required secrets
```
### 5. Monitor Secret Access
Use secret managers with audit logging:
```yaml
providers:
azure:
type: azure_key_vault
vault_url: "https://myvault.vault.azure.net/"
# Azure Key Vault provides detailed audit logs
```
## Troubleshooting
### Secret Not Found
```bash
Error: Secret 'API_KEY' not found
# Check:
1. Secret exists in the provider
2. Provider is correctly configured
3. Authentication is working
4. Correct provider name in ${{ secrets.provider:name }}
```
### Authentication Failed
```bash
Error: Authentication failed for provider 'vault'
# Check:
1. Credentials are correct
2. Network connectivity to secret manager
3. Permissions for the service account
4. Token/credential expiration
```
### Secret Masking Not Working
```bash
# Secrets appearing in logs
# Check:
1. enable_masking: true in configuration
2. Secret is properly retrieved (not hardcoded)
3. Secret matches known patterns for auto-masking
```
## Migration Guide
### From GitHub Actions
Most GitHub Actions workflows work without changes:
```yaml
# This works directly in wrkflw
steps:
- name: Deploy
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: deploy.sh
```
### From Environment Variables
```bash
# Before (environment variables)
export API_KEY="your_key"
./script.sh
# After (wrkflw secrets)
# Set in secrets.env:
# API_KEY=your_key
# Use in workflow:
# ${{ secrets.API_KEY }}
```
### From CI/CD Platforms
Most secrets can be migrated by:
1. Exporting from current platform
2. Importing into wrkflw's chosen provider
3. Updating workflow syntax to `${{ secrets.NAME }}`
## Performance Considerations
### Caching
Enable caching for frequently accessed secrets:
```yaml
enable_caching: true
cache_ttl_seconds: 300 # 5 minutes
```
### Connection Pooling
For high-volume deployments, secret managers support connection pooling:
```yaml
providers:
vault:
type: vault
# Vault client automatically handles connection pooling
```
### Timeout Configuration
Adjust timeouts based on network conditions:
```yaml
timeout_seconds: 30 # Increase for slow networks
```
## Conclusion
With comprehensive secrets management, wrkflw is now suitable for production workflows requiring secure access to:
- External APIs and services
- Databases and storage systems
- Cloud provider resources
- Authentication systems
- Deployment targets
The flexible provider system ensures compatibility with existing secret management infrastructure while providing a GitHub Actions-compatible developer experience.
**The usefulness limitation has been removed** - wrkflw can now handle real-world CI/CD scenarios securely and efficiently.

View File

@@ -0,0 +1,49 @@
# Example environment variables for wrkflw secrets demo
# Copy this file to .env and fill in your actual values
# GitHub integration
GITHUB_TOKEN=ghp_your_github_personal_access_token
# Generic API credentials
API_KEY=your_api_key_here
API_ENDPOINT=https://api.example.com/v1
# Database credentials
DB_USER=your_db_username
DB_PASSWORD=your_secure_db_password
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
MONGO_CONNECTION_STRING=mongodb://user:password@localhost:27017/dbname
# Docker registry credentials
DOCKER_USERNAME=your_docker_username
DOCKER_PASSWORD=your_docker_password
# AWS credentials
AWS_ACCESS_KEY_ID=your_aws_access_key_id
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
S3_BUCKET_NAME=your-s3-bucket-name
# Deployment credentials
STAGING_DEPLOY_KEY=your_base64_encoded_ssh_private_key
STAGING_HOST=staging.yourdomain.com
# Notification webhooks
WEBHOOK_URL=https://your.webhook.endpoint/path
SLACK_WEBHOOK=https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK
# Demo and testing secrets
DEMO_SECRET=this_will_be_masked_in_logs
REQUIRED_SECRET=required_for_validation_tests
# Prefixed secrets (if using WRKFLW_SECRET_ prefix)
WRKFLW_SECRET_PREFIXED_KEY=prefixed_secret_value
# Vault credentials (if using HashiCorp Vault)
VAULT_TOKEN=your_vault_token
VAULT_ROLE_ID=your_vault_role_id
VAULT_SECRET_ID=your_vault_secret_id
# Azure credentials (if using Azure Key Vault)
AZURE_CLIENT_ID=your_azure_client_id
AZURE_CLIENT_SECRET=your_azure_client_secret
AZURE_TENANT_ID=your_azure_tenant_id

View File

@@ -0,0 +1,213 @@
name: Comprehensive Secrets Demo
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# Basic environment variable secrets
env-secrets:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use GitHub Token
run: |
echo "Fetching user info from GitHub API"
curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/user | jq '.login'
- name: Use API Key
env:
API_KEY: ${{ secrets.API_KEY }}
run: |
echo "API Key length: ${#API_KEY}"
# Key will be masked in logs automatically
- name: Database connection
run: |
echo "Connecting to database with credentials"
echo "User: ${{ secrets.DB_USER }}"
echo "Password: [MASKED]"
# Password would be: ${{ secrets.DB_PASSWORD }}
# Provider-specific secrets
provider-secrets:
runs-on: ubuntu-latest
steps:
- name: Use file-based secrets
run: |
echo "File secret: ${{ secrets.file:FILE_SECRET }}"
- name: Use environment with prefix
run: |
echo "Prefixed secret: ${{ secrets.env:PREFIXED_KEY }}"
- name: Use Vault secret (if configured)
run: |
# This would work if Vault provider is configured
echo "Vault secret: ${{ secrets.vault:api-key }}"
- name: Use AWS Secrets Manager (if configured)
run: |
# This would work if AWS provider is configured
echo "AWS secret: ${{ secrets.aws:prod/database/password }}"
# Real-world integration examples
github-integration:
runs-on: ubuntu-latest
steps:
- name: Create GitHub issue
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/${{ github.repository }}/issues \
-d '{
"title": "Automated issue from wrkflw",
"body": "This issue was created automatically by wrkflw secrets demo",
"labels": ["automation", "demo"]
}'
- name: List repository secrets (admin only)
run: |
# This would require admin permissions
curl -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/actions/secrets
# Docker registry integration
docker-integration:
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
echo "Logging into Docker Hub"
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- name: Pull private image
run: |
docker pull private-registry.com/myapp:latest
- name: Push image
run: |
docker tag myapp:latest "${{ secrets.DOCKER_USERNAME }}/myapp:${{ github.sha }}"
docker push "${{ secrets.DOCKER_USERNAME }}/myapp:${{ github.sha }}"
# Cloud provider integration
aws-integration:
runs-on: ubuntu-latest
steps:
- name: Configure AWS credentials
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
echo "Configuring AWS CLI"
aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID"
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY"
aws configure set default.region "$AWS_DEFAULT_REGION"
- name: List S3 buckets
run: |
aws s3 ls
- name: Deploy to S3
run: |
echo "Deploying to S3 bucket"
aws s3 sync ./build/ s3://${{ secrets.S3_BUCKET_NAME }}/
# Database operations
database-operations:
runs-on: ubuntu-latest
steps:
- name: PostgreSQL operations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
PGPASSWORD: ${{ secrets.DB_PASSWORD }}
run: |
echo "Connecting to PostgreSQL database"
psql "$DATABASE_URL" -c "SELECT version();"
- name: MongoDB operations
env:
MONGO_CONNECTION_STRING: ${{ secrets.MONGO_CONNECTION_STRING }}
run: |
echo "Connecting to MongoDB"
mongosh "$MONGO_CONNECTION_STRING" --eval "db.stats()"
# API testing with secrets
api-testing:
runs-on: ubuntu-latest
steps:
- name: Test external API
env:
API_ENDPOINT: ${{ secrets.API_ENDPOINT }}
API_KEY: ${{ secrets.API_KEY }}
run: |
echo "Testing API endpoint"
curl -X GET \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$API_ENDPOINT/health"
- name: Test webhook
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d '{"event": "test", "source": "wrkflw"}' \
"${{ secrets.WEBHOOK_URL }}"
# Deployment with secrets
deployment:
runs-on: ubuntu-latest
needs: [env-secrets, api-testing]
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to staging
env:
DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
STAGING_HOST: ${{ secrets.STAGING_HOST }}
run: |
echo "Deploying to staging environment"
echo "$DEPLOY_KEY" | base64 -d > deploy_key
chmod 600 deploy_key
ssh -i deploy_key -o StrictHostKeyChecking=no \
deploy@"$STAGING_HOST" 'cd /app && git pull && ./deploy.sh'
- name: Notify deployment
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"Deployment completed successfully"}' \
"$SLACK_WEBHOOK"
# Security testing
security-demo:
runs-on: ubuntu-latest
steps:
- name: Demonstrate secret masking
run: |
echo "This secret will be masked: ${{ secrets.DEMO_SECRET }}"
echo "Even in complex strings: prefix_${{ secrets.DEMO_SECRET }}_suffix"
- name: Show environment (secrets masked)
run: |
env | grep -E "(SECRET|TOKEN|PASSWORD|KEY)" || echo "No secrets visible in environment"
- name: Test secret validation
run: |
# This would fail if secret doesn't exist
if [ -z "${{ secrets.REQUIRED_SECRET }}" ]; then
echo "ERROR: Required secret is missing"
exit 1
else
echo "Required secret is present"
fi

View File

@@ -0,0 +1,21 @@
{
"GITHUB_TOKEN": "ghp_example_token_replace_with_real_token",
"API_KEY": "demo_api_key_12345",
"DB_PASSWORD": "secure_database_password",
"DB_USER": "application_user",
"DOCKER_USERNAME": "your_docker_username",
"DOCKER_PASSWORD": "your_docker_password",
"AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE",
"AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"S3_BUCKET_NAME": "my-deployment-bucket",
"DATABASE_URL": "postgresql://user:password@localhost:5432/mydb",
"MONGO_CONNECTION_STRING": "mongodb://user:password@localhost:27017/mydb",
"API_ENDPOINT": "https://api.example.com/v1",
"WEBHOOK_URL": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
"STAGING_DEPLOY_KEY": "base64_encoded_ssh_private_key",
"STAGING_HOST": "staging.example.com",
"SLACK_WEBHOOK": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
"DEMO_SECRET": "this_will_be_masked_in_logs",
"REQUIRED_SECRET": "required_for_validation",
"FILE_SECRET": "stored_in_file_provider"
}

13
final-test.yml Normal file
View File

@@ -0,0 +1,13 @@
name: Final Secrets Test
on: [push]
jobs:
verify-secrets:
runs-on: ubuntu-latest
steps:
- name: Test secrets are working
env:
SECRET_VAL: ${{ secrets.TEST_SECRET }}
run: |
echo "Secret length: ${#SECRET_VAL}"
echo "All secrets functionality verified!"

46
working-secrets-test.yml Normal file
View File

@@ -0,0 +1,46 @@
name: Working Secrets Test
on: [push]
jobs:
test-secrets:
runs-on: ubuntu-latest
steps:
- name: Test environment variable secrets
env:
MY_SECRET: ${{ secrets.TEST_SECRET }}
API_KEY: ${{ secrets.API_KEY }}
run: |
echo "Secret length: ${#MY_SECRET}"
echo "API Key length: ${#API_KEY}"
echo "API Key exists: $([ -n "$API_KEY" ] && echo "yes" || echo "no")"
- name: Test direct secret usage in commands
run: |
echo "Using secret directly: ${{ secrets.TEST_SECRET }}"
echo "Using GitHub token: ${{ secrets.GITHUB_TOKEN }}"
- name: Test secret in variable assignment
run: |
SECRET_VAL="${{ secrets.TEST_SECRET }}"
echo "Secret value length: ${#SECRET_VAL}"
- name: Test multiple secrets in one command
run: |
echo "Token: ${{ secrets.GITHUB_TOKEN }}, Key: ${{ secrets.API_KEY }}"
test-masking:
runs-on: ubuntu-latest
steps:
- name: Test automatic token masking
run: |
echo "GitHub token should be masked: ${{ secrets.GITHUB_TOKEN }}"
echo "API key should be masked: ${{ secrets.API_KEY }}"
- name: Test pattern masking
env:
DEMO_TOKEN: ghp_1234567890abcdef1234567890abcdef12345678
AWS_KEY: AKIAIOSFODNN7EXAMPLE
run: |
echo "Demo GitHub token: $DEMO_TOKEN"
echo "Demo AWS key: $AWS_KEY"
echo "These should be automatically masked"