mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-31 15:54:24 +00:00
Compare commits
553
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ad662105 | ||
|
|
fc31ff260d | ||
|
|
b5d086235f | ||
|
|
ba2d73a6d9 | ||
|
|
b50a7c5567 | ||
|
|
8ca66a62ff | ||
|
|
af0476124a | ||
|
|
f626aa7a87 | ||
|
|
7a78ca970a | ||
|
|
008864797b | ||
|
|
6999bbac9b | ||
|
|
2cc5c4b2f9 | ||
|
|
1a62d7d99a | ||
|
|
162996fa0a | ||
|
|
219304848d | ||
|
|
b036b8f42d | ||
|
|
cbece30d7b | ||
|
|
90e1d21db6 | ||
|
|
714d9283b0 | ||
|
|
8e83de67bc | ||
|
|
ca6ed48664 | ||
|
|
814ae54d6a | ||
|
|
53b7b24e2b | ||
|
|
e74a9a909e | ||
|
|
5a94fc798d | ||
|
|
78a5fa139f | ||
|
|
06a20bcc7c | ||
|
|
c0891591a3 | ||
|
|
4b80c69739 | ||
|
|
905c60fa85 | ||
|
|
dcd3d5a0fb | ||
|
|
75c9a6bf51 | ||
|
|
4eb468619f | ||
|
|
d7bffba4ac | ||
|
|
a5995e1438 | ||
|
|
1a30b374d6 | ||
|
|
d6ea8bf274 | ||
|
|
b4bbb64c6b | ||
|
|
fdb1bd6193 | ||
|
|
41a2bf605a | ||
|
|
63b8c8969c | ||
|
|
1ae3856de0 | ||
|
|
40758818c3 | ||
|
|
83152ae1a6 | ||
|
|
3bbd551f0f | ||
|
|
be01a97789 | ||
|
|
7ecb6aaefd | ||
|
|
75f81cc751 | ||
|
|
c724840e6a | ||
|
|
b52b8d3678 | ||
|
|
c533b47aeb | ||
|
|
669df91ac9 | ||
|
|
35d25a7218 | ||
|
|
819f5fd7a0 | ||
|
|
7d71220a25 | ||
|
|
69b80f6a3f | ||
|
|
f26b9776d4 | ||
|
|
54f2400aee | ||
|
|
8c5357b087 | ||
|
|
13202b8a70 | ||
|
|
680acf8b31 | ||
|
|
1468596b61 | ||
|
|
02c8881378 | ||
|
|
eae17a5e17 | ||
|
|
a59eae7b24 | ||
|
|
9a89dc4a4e | ||
|
|
1e2f8f3cf7 | ||
|
|
d9ace56e43 | ||
|
|
c0ce556ea1 | ||
|
|
34e0af33c2 | ||
|
|
6ee0dd9730 | ||
|
|
4980ded0ae | ||
|
|
4d922da2a6 | ||
|
|
76cde39b08 | ||
|
|
414dd64545 | ||
|
|
3654e5a810 | ||
|
|
7f9eb2cb11 | ||
|
|
ba0e78188b | ||
|
|
1783527c11 | ||
|
|
f46b86101a | ||
|
|
f45173997b | ||
|
|
136e4efdb5 | ||
|
|
de4db487d5 | ||
|
|
f5aaec21cc | ||
|
|
64ce5a7503 | ||
|
|
2600d49195 | ||
|
|
7c4b12b655 | ||
|
|
9b56a3b657 | ||
|
|
f70b648cde | ||
|
|
1f8ca695e8 | ||
|
|
d8a4e98850 | ||
|
|
d9b53b2637 | ||
|
|
a4d07678b4 | ||
|
|
64c19b3db6 | ||
|
|
286ae6c7cf | ||
|
|
8e67f0251c | ||
|
|
3c50cec509 | ||
|
|
fbb22834f7 | ||
|
|
165b62a730 | ||
|
|
8b814b7643 | ||
|
|
22468c1443 | ||
|
|
a36991f60e | ||
|
|
dd63fa960b | ||
|
|
80c4113879 | ||
|
|
c438858dbd | ||
|
|
f606a3c195 | ||
|
|
c599a9cf1d | ||
|
|
61d2cc55c4 | ||
|
|
859a846147 | ||
|
|
d8bdc8553f | ||
|
|
f44ea4b6fa | ||
|
|
27c4113d29 | ||
|
|
cf688e3fa1 | ||
|
|
92216f1597 | ||
|
|
b5a709664d | ||
|
|
9d8060adc5 | ||
|
|
f1e822f7df | ||
|
|
22bc834c09 | ||
|
|
3f893176ba | ||
|
|
d38e04dd3e | ||
|
|
7ccdf7c0e6 | ||
|
|
bee60c5471 | ||
|
|
f850199645 | ||
|
|
0321a4f167 | ||
|
|
383a9bf903 | ||
|
|
4605d9a770 | ||
|
|
f0da33f451 | ||
|
|
a44a725507 | ||
|
|
b8ca30400b | ||
|
|
a006525e78 | ||
|
|
ed510e217d | ||
|
|
04511f339e | ||
|
|
f418b32c81 | ||
|
|
65fb146e76 | ||
|
|
5ea363d582 | ||
|
|
3ec379a75a | ||
|
|
ea020ed46e | ||
|
|
60b936005c | ||
|
|
03502c2426 | ||
|
|
e6320b178a | ||
|
|
4195a4f818 | ||
|
|
8d003c60d0 | ||
|
|
d5136f364c | ||
|
|
453c953f37 | ||
|
|
c114aa304b | ||
|
|
c2b8092461 | ||
|
|
26d9d24e5d | ||
|
|
ee59e35bc2 | ||
|
|
e0fa23b9ee | ||
|
|
4dbc3c7e74 | ||
|
|
576433d85d | ||
|
|
bf179212a5 | ||
|
|
60f936a10b | ||
|
|
15630f54d2 | ||
|
|
706603fe4d | ||
|
|
dc67fe4f37 | ||
|
|
2c233c2f6c | ||
|
|
69e1d19abe | ||
|
|
859421b0ec | ||
|
|
ea36f12a01 | ||
|
|
50a9138bea | ||
|
|
09821e6328 | ||
|
|
94899e20fd | ||
|
|
79da676c86 | ||
|
|
8b35b69731 | ||
|
|
81d90fc31e | ||
|
|
8b5f1250a9 | ||
|
|
98d2db4e13 | ||
|
|
c9068515b2 | ||
|
|
3823186914 | ||
|
|
03cb59c20d | ||
|
|
d819639f0e | ||
|
|
6c8d6994bb | ||
|
|
07d48ab15d | ||
|
|
5fe1a6713b | ||
|
|
61d13f078c | ||
|
|
bbb297aee7 | ||
|
|
d0d29108e0 | ||
|
|
f382400037 | ||
|
|
396ccf56bb | ||
|
|
3069486549 | ||
|
|
9f616a5d43 | ||
|
|
2198ba2d84 | ||
|
|
974f4251dd | ||
|
|
75c22df63b | ||
|
|
757cd44ecb | ||
|
|
c0fdd5bdf3 | ||
|
|
61ef44a03b | ||
|
|
682353e054 | ||
|
|
3860cd3dce | ||
|
|
1b228d590b | ||
|
|
8b265a8314 | ||
|
|
ba258a5346 | ||
|
|
282fe5b46b | ||
|
|
c08cb65d84 | ||
|
|
4727da7ca7 | ||
|
|
b378f15798 | ||
|
|
f9c02da90a | ||
|
|
865eebe7fb | ||
|
|
8270979ec8 | ||
|
|
369f852837 | ||
|
|
1e8454a3cb | ||
|
|
6f6ae88a66 | ||
|
|
c71f226e5c | ||
|
|
bbf5e1fa9f | ||
|
|
9cab9b8653 | ||
|
|
806be2d061 | ||
|
|
2769cb9876 | ||
|
|
623dc3125a | ||
|
|
1edcf6e5de | ||
|
|
8bdb597921 | ||
|
|
1746e1052b | ||
|
|
6827eef347 | ||
|
|
90fc815d3c | ||
|
|
8f35fff255 | ||
|
|
ab51d09543 | ||
|
|
12faeb9aa2 | ||
|
|
9ed07de610 | ||
|
|
8007501574 | ||
|
|
36514534cb | ||
|
|
9621bdfb9c | ||
|
|
f013a5e1ad | ||
|
|
18453e592e | ||
|
|
86e4408f29 | ||
|
|
ae43d21efb | ||
|
|
7c84822fa3 | ||
|
|
51c5fa7168 | ||
|
|
e9121f5f1a | ||
|
|
821fe43efd | ||
|
|
9ffbb4b758 | ||
|
|
9c5285adc3 | ||
|
|
f295d290dd | ||
|
|
e5df95c259 | ||
|
|
b6a8af3c54 | ||
|
|
fb7064401b | ||
|
|
8d60f9703a | ||
|
|
ceb601028e | ||
|
|
6422178b76 | ||
|
|
587c47bfe2 | ||
|
|
13a31d9225 | ||
|
|
0715619435 | ||
|
|
1679094f22 | ||
|
|
f05a490cd7 | ||
|
|
89988dada5 | ||
|
|
0326844527 | ||
|
|
73d5c6952b | ||
|
|
ad76b3026f | ||
|
|
e21946874f | ||
|
|
2cae2058e9 | ||
|
|
c88f745038 | ||
|
|
c923c58a39 | ||
|
|
c3bee8c21e | ||
|
|
4d13e8432e | ||
|
|
5f24bec9fe | ||
|
|
afefb8f333 | ||
|
|
2b81fdcc04 | ||
|
|
db298c1d48 | ||
|
|
2877c3d6c0 | ||
|
|
ee64c17108 | ||
|
|
a610314eba | ||
|
|
301edea7ce | ||
|
|
c89d900aae | ||
|
|
4cfb4eeb96 | ||
|
|
f19478f2f6 | ||
|
|
2f11b16299 | ||
|
|
654d2c9f17 | ||
|
|
2721d42594 | ||
|
|
bd1956446d | ||
|
|
d26d7cf91a | ||
|
|
4c20bf1fac | ||
|
|
9dc53ffc60 | ||
|
|
95642fb220 | ||
|
|
6449f3a592 | ||
|
|
91e6cb798d | ||
|
|
bcd37988b5 | ||
|
|
7a64e1a1d1 | ||
|
|
39c85ffb77 | ||
|
|
301ca50541 | ||
|
|
f299e1d9ac | ||
|
|
465e35bf54 | ||
|
|
829af2e3f7 | ||
|
|
51c65bdc69 | ||
|
|
d0c088dd0a | ||
|
|
411d112165 | ||
|
|
8a4cc8780d | ||
|
|
cd4d2a27e3 | ||
|
|
9898cf7364 | ||
|
|
83d8cfa829 | ||
|
|
5202a68cf0 | ||
|
|
e3a3acc799 | ||
|
|
3e000faa31 | ||
|
|
f39c92b8f8 | ||
|
|
823efc5ab1 | ||
|
|
75d7fa5006 | ||
|
|
db25484ccb | ||
|
|
3da4209ee7 | ||
|
|
acb6ff0425 | ||
|
|
ba564af4f4 | ||
|
|
0b9791ffdc | ||
|
|
f6defefb58 | ||
|
|
f3224d0988 | ||
|
|
450e6ba553 | ||
|
|
2cd93fe119 | ||
|
|
f807b22ea6 | ||
|
|
b6e9967da6 | ||
|
|
16e62f7514 | ||
|
|
13ce9436b3 | ||
|
|
d3ced63397 | ||
|
|
758b696ca5 | ||
|
|
0d3ce45374 | ||
|
|
f35666ff0a | ||
|
|
0758c3585d | ||
|
|
dd882c70e7 | ||
|
|
d05c9fbb31 | ||
|
|
7bde42ffb9 | ||
|
|
0d3df0fd0b | ||
|
|
ab996417e6 | ||
|
|
5f109bc00e | ||
|
|
472e04f4cc | ||
|
|
b848aace33 | ||
|
|
94c20eb9fe | ||
|
|
02df22ca19 | ||
|
|
de64df11b9 | ||
|
|
37ebd9b6fd | ||
|
|
a28487cbff | ||
|
|
68471d2a0e | ||
|
|
d41b2eaa0f | ||
|
|
b480907484 | ||
|
|
6d7bc8a86e | ||
|
|
8bfca81e4b | ||
|
|
931612443a | ||
|
|
3074f02a63 | ||
|
|
5cfc22040a | ||
|
|
48ba1692e1 | ||
|
|
a8b12813f9 | ||
|
|
85c36bb812 | ||
|
|
ce037318cd | ||
|
|
356036fe1f | ||
|
|
286685a4f3 | ||
|
|
9daca2e4df | ||
|
|
3ca3a977a9 | ||
|
|
561a1390be | ||
|
|
f2a00f19aa | ||
|
|
34b4e6f016 | ||
|
|
94594d6766 | ||
|
|
6e71dee85d | ||
|
|
90712c9ad7 | ||
|
|
3672b17a00 | ||
|
|
fd555e2989 | ||
|
|
cf558c5f0a | ||
|
|
e2cae35d38 | ||
|
|
d8c8027215 | ||
|
|
226504982b | ||
|
|
a700865340 | ||
|
|
07faddd64e | ||
|
|
1b9a44b164 | ||
|
|
ff0ee666e3 | ||
|
|
b684ad06f3 | ||
|
|
cd4693168a | ||
|
|
f727f1bb50 | ||
|
|
76d7a2882c | ||
|
|
c7adc14729 | ||
|
|
31d8faccfa | ||
|
|
d0da56f352 | ||
|
|
5e41b2054d | ||
|
|
058db7bcc9 | ||
|
|
d1a37039fd | ||
|
|
97c342ad80 | ||
|
|
f3c602a5ac | ||
|
|
d7816f1179 | ||
|
|
dd61c417b7 | ||
|
|
2fdd46336c | ||
|
|
ae8c86ecb5 | ||
|
|
aaa29d3528 | ||
|
|
06799dcaa8 | ||
|
|
b53c5a4e70 | ||
|
|
af757a4d69 | ||
|
|
f87522e423 | ||
|
|
d3524d50fb | ||
|
|
5b1bd146be | ||
|
|
34431b5b88 | ||
|
|
5285d25cfd | ||
|
|
a6d5dbacd9 | ||
|
|
3b577907e4 | ||
|
|
8bf788ea95 | ||
|
|
87bc1eceae | ||
|
|
635e451d9b | ||
|
|
bd6aec8c20 | ||
|
|
3c14df7e5b | ||
|
|
531f61df2f | ||
|
|
765a1596f9 | ||
|
|
138d643119 | ||
|
|
c74eac1369 | ||
|
|
f9dbb0eee9 | ||
|
|
ab13d111c2 | ||
|
|
f0d2972969 | ||
|
|
90905dcc9f | ||
|
|
5cf49805a2 | ||
|
|
c610d9ac31 | ||
|
|
fb9d989be8 | ||
|
|
64c0cf900f | ||
|
|
0f39665ece | ||
|
|
162c6560d9 | ||
|
|
681be7537d | ||
|
|
94254555a4 | ||
|
|
2646068e7e | ||
|
|
caf27de6ee | ||
|
|
aab8154139 | ||
|
|
1de779c978 | ||
|
|
f4d6cd8609 | ||
|
|
b3d174d0c1 | ||
|
|
8ebb4a1ee7 | ||
|
|
abf660ce06 | ||
|
|
a19fd70001 | ||
|
|
9b6a239abe | ||
|
|
8c0fbf5073 | ||
|
|
ce77eb7f41 | ||
|
|
e15f6970ef | ||
|
|
b9aa863e52 | ||
|
|
39cbcbfe2b | ||
|
|
f19106281b | ||
|
|
137b388df9 | ||
|
|
e0cb18bdbd | ||
|
|
b08d2eb472 | ||
|
|
74a760517e | ||
|
|
2db3bebd15 | ||
|
|
b56df840fc | ||
|
|
bd9fa88d2a | ||
|
|
9adc248c05 | ||
|
|
ea0c7eb271 | ||
|
|
88c666a0d2 | ||
|
|
7275b46707 | ||
|
|
60bc06271a | ||
|
|
8abd72e857 | ||
|
|
9185b043da | ||
|
|
3dd6b29477 | ||
|
|
6db407ed3c | ||
|
|
0b98a34687 | ||
|
|
fc0204a40d | ||
|
|
7a62926a09 | ||
|
|
7a6a35afec | ||
|
|
b7281a5221 | ||
|
|
5c4b0ba1fe | ||
|
|
f3b8ac1dbb | ||
|
|
8dac2a7ccf | ||
|
|
6192b8ac32 | ||
|
|
e49babb9e7 | ||
|
|
f8be9afa7c | ||
|
|
ecf7ec8e85 | ||
|
|
a57a507cee | ||
|
|
9b565586da | ||
|
|
17f726f816 | ||
|
|
3fd748994a | ||
|
|
976220dbf5 | ||
|
|
a77e56b5a0 | ||
|
|
34e4d25576 | ||
|
|
d4a33c0d1c | ||
|
|
4c3017e2ed | ||
|
|
03f2ab46c9 | ||
|
|
7f1cdb82ae | ||
|
|
1bb3fc9bda | ||
|
|
463e8309f7 | ||
|
|
59a7d30a3e | ||
|
|
2ae1062e76 | ||
|
|
1218b0920f | ||
|
|
06ea61ffbf | ||
|
|
8eeb37aea4 | ||
|
|
7ba96ab2e2 | ||
|
|
4987d8a08e | ||
|
|
2bd89ceb97 | ||
|
|
0d4a21b5a4 | ||
|
|
1460f7b188 | ||
|
|
e9bbde2f01 | ||
|
|
76239b7076 | ||
|
|
0c0f150cd4 | ||
|
|
43de7709cb | ||
|
|
bb18dcb882 | ||
|
|
f5fe9c7b40 | ||
|
|
60c81f6549 | ||
|
|
f18c2841a8 | ||
|
|
6b21e31a28 | ||
|
|
294e665d9e | ||
|
|
94e14660da | ||
|
|
339930ef13 | ||
|
|
d933a8ecab | ||
|
|
da09ad9813 | ||
|
|
2298d4a3f8 | ||
|
|
066d53467a | ||
|
|
cf433128ed | ||
|
|
0b782fcb8c | ||
|
|
b80e3a7bfb | ||
|
|
885555e080 | ||
|
|
641c418816 | ||
|
|
10d173f8da | ||
|
|
34de660755 | ||
|
|
fb75146e34 | ||
|
|
9f5ef80e69 | ||
|
|
2f6aedf291 | ||
|
|
dcf2736e8d | ||
|
|
7f0dc9b7da | ||
|
|
ff45f46047 | ||
|
|
3bd13d173d | ||
|
|
2b7f7e7dc0 | ||
|
|
98015dafef | ||
|
|
d70a7e3d02 | ||
|
|
7d2a22c45a | ||
|
|
bf82e9ff3d | ||
|
|
eece938350 | ||
|
|
2b0e34818c | ||
|
|
f587dbf419 | ||
|
|
2d684c1996 | ||
|
|
7f9d64a996 | ||
|
|
e943ded978 | ||
|
|
bb20f69a63 | ||
|
|
97233189c3 | ||
|
|
b624818b8e | ||
|
|
cb31856025 | ||
|
|
13a9caa803 | ||
|
|
4e22289a19 | ||
|
|
e035e0ff62 | ||
|
|
dd81793480 | ||
|
|
457297a5f6 | ||
|
|
4da5aed519 | ||
|
|
95521d26cb | ||
|
|
cbe06314ca | ||
|
|
4b72cc8dd4 | ||
|
|
ce9d46065a | ||
|
|
35b3ff2c8e | ||
|
|
d7d4cc4849 | ||
|
|
1459046985 | ||
|
|
84c3c9ce0a | ||
|
|
8271659fda | ||
|
|
9916e1ac66 | ||
|
|
ccec96ac5f | ||
|
|
f5ea116763 | ||
|
|
99ca260855 | ||
|
|
bfc6b9e577 | ||
|
|
bc3c922177 | ||
|
|
0e20d2388e | ||
|
|
e0ddc0de27 | ||
|
|
cf840588c7 | ||
|
|
e1c2e9373c | ||
|
|
641a11e4a0 | ||
|
|
59b13778e2 | ||
|
|
7d8c64d35b | ||
|
|
a022ad5c7a | ||
|
|
0f31f1a3c2 | ||
|
|
1ee71c1f20 | ||
|
|
0dc424031b | ||
|
|
0d899b3076 | ||
|
|
3fd9fca32f | ||
|
|
07d7e9d5bb | ||
|
|
31b8ab9b4b |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "prowler-plugins",
|
||||
"description": "Prowler Cloud Security for Claude Code",
|
||||
"description": "Prowler Cloud security and compliance for Claude",
|
||||
"owner": {
|
||||
"name": "Prowler",
|
||||
"email": "support@prowler.com"
|
||||
@@ -9,7 +9,7 @@
|
||||
{
|
||||
"name": "prowler",
|
||||
"source": "./claude_plugins/prowler",
|
||||
"description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
|
||||
"description": "Connect Claude to Prowler Cloud through the Prowler MCP server. Reads providers, scans, findings, compliance results and resources for AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and the other providers listed at https://registry.prowler.com, and returns remediation guidance for failed checks. Writes are limited to Prowler Cloud itself: triggering scans, finding triage, mute rules and the mutelist, providers and integrations. Your cloud resources are not changed by the connector.",
|
||||
"category": "security",
|
||||
"homepage": "https://prowler.com"
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ NEO4J_APOC_IMPORT_FILE_ENABLED=false
|
||||
NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true
|
||||
NEO4J_APOC_TRIGGER_ENABLED=false
|
||||
NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
|
||||
# Neo4j Prowler settings
|
||||
ATTACK_PATHS_BATCH_SIZE=1000
|
||||
# Attack Paths graph settings
|
||||
ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE=1000
|
||||
ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES=3
|
||||
ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS=30
|
||||
ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES=250
|
||||
@@ -110,11 +110,27 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY=""
|
||||
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN=""
|
||||
|
||||
# The AWS region where your S3 bucket is located (e.g., "us-east-1")
|
||||
# Required if the bucket uses SSE-KMS: download URLs are then signed with SigV4, which
|
||||
# is scoped to this region, so it must match the bucket's
|
||||
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION=""
|
||||
|
||||
# The name of the S3 bucket where scan output should be stored
|
||||
DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET=""
|
||||
|
||||
# The storage endpoint the API and Celery workers use to upload and list scan output
|
||||
# (e.g. "http://minio:9000"). Leave empty on AWS S3. Set it when scan output is stored on
|
||||
# S3-compatible object storage such as MinIO instead of real S3.
|
||||
# If set without DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL below, report download URLs are
|
||||
# signed against this internal host, and a browser outside the container network cannot open them.
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL=""
|
||||
|
||||
# The storage address the browser can reach, used only to sign report download URLs
|
||||
# (e.g. "https://storage.example.com"). Leave empty on AWS S3. Set it when storage is
|
||||
# only reachable inside the container network, such as MinIO on "http://minio:9000".
|
||||
# The reverse proxy in front of it must forward the Host header unchanged: SigV4 signs
|
||||
# Host, so rewriting it to the internal name invalidates the signature.
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL=""
|
||||
|
||||
# Django settings
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api
|
||||
DJANGO_BIND_ADDRESS=0.0.0.0
|
||||
@@ -158,7 +174,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.34.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.48.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
+20
-20
@@ -1,28 +1,28 @@
|
||||
# SDK
|
||||
/* @prowler-cloud/detection-remediation
|
||||
/prowler/ @prowler-cloud/detection-remediation
|
||||
/tests/ @prowler-cloud/detection-remediation
|
||||
/dashboard/ @prowler-cloud/detection-remediation
|
||||
/docs/ @prowler-cloud/detection-remediation
|
||||
/examples/ @prowler-cloud/detection-remediation
|
||||
/util/ @prowler-cloud/detection-remediation
|
||||
/contrib/ @prowler-cloud/detection-remediation
|
||||
/permissions/ @prowler-cloud/detection-remediation
|
||||
/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api
|
||||
/* @prowler-cloud/engineering
|
||||
/prowler/ @prowler-cloud/engineering
|
||||
/tests/ @prowler-cloud/engineering
|
||||
/dashboard/ @prowler-cloud/engineering
|
||||
/docs/ @prowler-cloud/engineering
|
||||
/examples/ @prowler-cloud/engineering
|
||||
/util/ @prowler-cloud/engineering
|
||||
/contrib/ @prowler-cloud/engineering
|
||||
/permissions/ @prowler-cloud/engineering
|
||||
/codecov.yml @prowler-cloud/engineering
|
||||
|
||||
# API
|
||||
/api/ @prowler-cloud/api
|
||||
/api/ @prowler-cloud/engineering
|
||||
|
||||
# UI
|
||||
/ui/ @prowler-cloud/ui
|
||||
/ui/ @prowler-cloud/engineering
|
||||
|
||||
# AI
|
||||
/mcp_server/ @prowler-cloud/detection-remediation
|
||||
/mcp_server/ @prowler-cloud/engineering
|
||||
|
||||
# Platform
|
||||
/.github/ @prowler-cloud/platform
|
||||
/Makefile @prowler-cloud/platform
|
||||
/kubernetes/ @prowler-cloud/platform
|
||||
**/Dockerfile* @prowler-cloud/platform
|
||||
**/docker-compose*.yml @prowler-cloud/platform
|
||||
**/docker-compose*.yaml @prowler-cloud/platform
|
||||
# CI and infrastructure
|
||||
/.github/ @prowler-cloud/engineering
|
||||
/Makefile @prowler-cloud/engineering
|
||||
/kubernetes/ @prowler-cloud/engineering
|
||||
**/Dockerfile* @prowler-cloud/engineering
|
||||
**/docker-compose*.yml @prowler-cloud/engineering
|
||||
**/docker-compose*.yaml @prowler-cloud/engineering
|
||||
@@ -0,0 +1,6 @@
|
||||
# Generated by gh-aw and marked DO NOT EDIT. It uses concurrency options newer than
|
||||
# actionlint knows, so the findings are about actionlint's schema, not our workflows.
|
||||
paths:
|
||||
.github/workflows/**/*.lock.yml:
|
||||
ignore:
|
||||
- '.*'
|
||||
@@ -0,0 +1,179 @@
|
||||
name: 'Container Security Scan with Grype'
|
||||
description: 'Scans container images for vulnerabilities using Grype and reports results'
|
||||
author: 'Prowler'
|
||||
|
||||
inputs:
|
||||
image-name:
|
||||
description: 'Container image name to scan'
|
||||
required: true
|
||||
image-tag:
|
||||
description: 'Container image tag to scan'
|
||||
required: true
|
||||
default: ${{ github.sha }}
|
||||
fail-on-severity:
|
||||
description: 'Fail the build on findings at this severity or above: critical, high, or none'
|
||||
required: false
|
||||
default: 'high'
|
||||
upload-sarif:
|
||||
description: 'Upload results to GitHub Security tab'
|
||||
required: false
|
||||
default: 'true'
|
||||
create-pr-comment:
|
||||
description: 'Create a comment on the PR with scan results'
|
||||
required: false
|
||||
default: 'true'
|
||||
artifact-retention-days:
|
||||
description: 'Days to retain the Grype report artifact'
|
||||
required: false
|
||||
default: '2'
|
||||
|
||||
outputs:
|
||||
critical-count:
|
||||
description: 'Number of critical vulnerabilities found'
|
||||
value: ${{ steps.security-check.outputs.critical }}
|
||||
high-count:
|
||||
description: 'Number of high vulnerabilities found'
|
||||
value: ${{ steps.security-check.outputs.high }}
|
||||
total-count:
|
||||
description: 'Total number of vulnerabilities found'
|
||||
value: ${{ steps.security-check.outputs.total }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Grype vulnerability scan (JSON)
|
||||
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
|
||||
with:
|
||||
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
output-format: 'json'
|
||||
output-file: 'grype-report.json'
|
||||
fail-build: 'false'
|
||||
by-cve: 'true' # Report CVE ids rather than GHSA, so findings line up with Trivy's
|
||||
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
|
||||
cache-db: 'true'
|
||||
grype-version: 'v0.116.1'
|
||||
|
||||
- name: Run Grype vulnerability scan (SARIF)
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
|
||||
with:
|
||||
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
output-format: 'sarif'
|
||||
output-file: 'grype-results.sarif'
|
||||
fail-build: 'false'
|
||||
severity-cutoff: 'high'
|
||||
by-cve: 'true'
|
||||
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
|
||||
cache-db: 'true'
|
||||
grype-version: 'v0.116.1'
|
||||
|
||||
- name: Upload Grype results to GitHub Security tab
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
with:
|
||||
sarif_file: 'grype-results.sarif'
|
||||
category: 'grype-container'
|
||||
|
||||
- name: Upload Grype report artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always()
|
||||
with:
|
||||
name: grype-scan-report-${{ inputs.image-name }}-${{ inputs.image-tag }}
|
||||
path: grype-report.json
|
||||
retention-days: ${{ inputs.artifact-retention-days }}
|
||||
|
||||
- name: Generate security summary
|
||||
id: security-check
|
||||
shell: bash
|
||||
run: |
|
||||
CRITICAL=$(jq '[.matches[]? | select(.vulnerability.severity=="Critical")] | length' grype-report.json)
|
||||
HIGH=$(jq '[.matches[]? | select(.vulnerability.severity=="High")] | length' grype-report.json)
|
||||
TOTAL=$(jq '[.matches[]?] | length' grype-report.json)
|
||||
|
||||
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
|
||||
echo "high=$HIGH" >> $GITHUB_OUTPUT
|
||||
echo "total=$TOTAL" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "### 🔎 Container Security Scan (Grype)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Image:** \`${INPUTS_IMAGE_NAME}:${INPUTS_IMAGE_TAG}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 🔴 Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 🟠 High: $HIGH" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Total**: $TOTAL" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Reported alongside Trivy, not instead of it. Counts differ by design." >> $GITHUB_STEP_SUMMARY
|
||||
env:
|
||||
INPUTS_IMAGE_NAME: ${{ inputs.image-name }}
|
||||
INPUTS_IMAGE_TAG: ${{ inputs.image-tag }}
|
||||
|
||||
# Before the gate, so the comment is there to explain a failure rather than absent because of it
|
||||
- name: Comment scan results on PR
|
||||
if: >-
|
||||
inputs.create-pr-comment == 'true'
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
IMAGE_NAME: ${{ inputs.image-name }}
|
||||
GITHUB_SHA: ${{ inputs.image-tag }}
|
||||
CUTOFF: ${{ inputs.fail-on-severity }}
|
||||
with:
|
||||
script: |
|
||||
const comment = require('./.github/scripts/grype-pr-comment.js');
|
||||
|
||||
// Unique identifier to find our comment
|
||||
const marker = `<!-- grype-scan-comment:${process.env.IMAGE_NAME} -->`;
|
||||
const body = marker + '\n' + comment;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existingComment = comments.find(c => c.body?.includes(marker));
|
||||
|
||||
if (existingComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
body: body
|
||||
});
|
||||
console.log('✅ Updated existing Grype scan comment');
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body
|
||||
});
|
||||
console.log('✅ Created new Grype scan comment');
|
||||
}
|
||||
|
||||
- name: Check for blocking vulnerabilities
|
||||
if: inputs.fail-on-severity != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$CUTOFF" = "critical" ]; then
|
||||
BLOCKING=$CRITICAL
|
||||
SEVERITIES='["Critical"]'
|
||||
else
|
||||
BLOCKING=$((CRITICAL + HIGH))
|
||||
SEVERITIES='["Critical","High"]'
|
||||
fi
|
||||
|
||||
if [ "$BLOCKING" -gt 0 ]; then
|
||||
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
|
||||
echo "::warning::Update the package, or add it to .grype.yaml with a reason if nothing can be done"
|
||||
jq -r --argjson severities "$SEVERITIES" \
|
||||
'.matches[] | select(.vulnerability.severity | IN($severities[]))
|
||||
| " \(.vulnerability.severity)\t\(.vulnerability.id)\t\(.artifact.name) \(.artifact.version)"' \
|
||||
grype-report.json | sort -u
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
CUTOFF: ${{ inputs.fail-on-severity }}
|
||||
CRITICAL: ${{ steps.security-check.outputs.critical }}
|
||||
HIGH: ${{ steps.security-check.outputs.high }}
|
||||
@@ -46,6 +46,17 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if grep -q "prowler-cloud/prowler" uv.lock; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if [ "$status" -ne 1 ]; then
|
||||
echo "::error::grep failed reading uv.lock (exit code $status)."
|
||||
exit "$status"
|
||||
fi
|
||||
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
|
||||
exit 0
|
||||
fi
|
||||
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
@@ -66,6 +77,17 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if grep -q "prowler-cloud/prowler" uv.lock; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if [ "$status" -ne 1 ]; then
|
||||
echo "::error::grep failed reading uv.lock (exit code $status)."
|
||||
exit "$status"
|
||||
fi
|
||||
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
|
||||
exit 0
|
||||
fi
|
||||
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
|
||||
@@ -14,10 +14,10 @@ inputs:
|
||||
description: 'Severities to scan for (comma-separated)'
|
||||
required: false
|
||||
default: 'CRITICAL,HIGH,MEDIUM,LOW'
|
||||
fail-on-critical:
|
||||
description: 'Fail the build if critical vulnerabilities are found'
|
||||
fail-on-severity:
|
||||
description: 'Fail the build on findings at this severity or above: critical, high, or none'
|
||||
required: false
|
||||
default: 'false'
|
||||
default: 'high'
|
||||
upload-sarif:
|
||||
description: 'Upload results to GitHub Security tab'
|
||||
required: false
|
||||
@@ -54,7 +54,7 @@ runs:
|
||||
trivy-db-${{ runner.os }}-
|
||||
|
||||
- name: Run Trivy vulnerability scan (JSON)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
format: 'json'
|
||||
@@ -62,12 +62,16 @@ runs:
|
||||
severity: ${{ inputs.severity }}
|
||||
exit-code: '0'
|
||||
scanners: 'vuln'
|
||||
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
|
||||
timeout: '5m'
|
||||
version: 'v0.71.2'
|
||||
version: 'v0.74.0'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
|
||||
- name: Run Trivy vulnerability scan (SARIF)
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
format: 'sarif'
|
||||
@@ -75,8 +79,12 @@ runs:
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '0'
|
||||
scanners: 'vuln'
|
||||
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
|
||||
timeout: '5m'
|
||||
version: 'v0.71.2'
|
||||
version: 'v0.74.0'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security tab
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
@@ -163,13 +171,28 @@ runs:
|
||||
console.log('✅ Created new Trivy scan comment');
|
||||
}
|
||||
|
||||
- name: Check for critical vulnerabilities
|
||||
if: inputs.fail-on-critical == 'true' && steps.security-check.outputs.critical != '0'
|
||||
- name: Check for blocking vulnerabilities
|
||||
if: inputs.fail-on-severity != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::error::Found ${STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL} critical vulnerabilities"
|
||||
echo "::warning::Please update packages or use a different base image"
|
||||
exit 1
|
||||
if [ "$CUTOFF" = "critical" ]; then
|
||||
BLOCKING=$CRITICAL
|
||||
SEVERITIES='["CRITICAL"]'
|
||||
else
|
||||
BLOCKING=$((CRITICAL + HIGH))
|
||||
SEVERITIES='["CRITICAL","HIGH"]'
|
||||
fi
|
||||
|
||||
if [ "$BLOCKING" -gt 0 ]; then
|
||||
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
|
||||
echo "::warning::Update the package, or add it to .trivyignore.yaml with a reason if nothing can be done"
|
||||
jq -r --argjson severities "$SEVERITIES" \
|
||||
'.Results[]?.Vulnerabilities[]? | select(.Severity | IN($severities[]))
|
||||
| " \(.Severity)\t\(.VulnerabilityID)\t\(.PkgName) \(.InstalledVersion)"' \
|
||||
trivy-report.json | sort -u
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL: ${{ steps.security-check.outputs.critical }}
|
||||
CUTOFF: ${{ inputs.fail-on-severity }}
|
||||
CRITICAL: ${{ steps.security-check.outputs.critical }}
|
||||
HIGH: ${{ steps.security-check.outputs.high }}
|
||||
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
|
||||
|
||||
### For Check Logic Bug
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Check Logic Bug
|
||||
|
||||
**Component**: {component from issue template}
|
||||
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Bug (non-check)
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Bug
|
||||
|
||||
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
|
||||
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Already Fixed
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Already Fixed
|
||||
|
||||
**Component**: {component}
|
||||
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Feature Request
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Feature Request
|
||||
|
||||
**Component**: {component}
|
||||
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Not a Bug
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Not a Bug
|
||||
|
||||
**Component**: {component}
|
||||
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Needs More Information
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Needs More Information
|
||||
|
||||
**Component**: {component or "Unknown"}
|
||||
|
||||
@@ -52,6 +52,16 @@ provider/alibabacloud:
|
||||
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
|
||||
|
||||
provider/huaweicloud:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
|
||||
|
||||
provider/image:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/image/**"
|
||||
- any-glob-to-any-file: "tests/providers/image/**"
|
||||
|
||||
provider/cloudflare:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
|
||||
@@ -82,6 +92,11 @@ provider/linode:
|
||||
- any-glob-to-any-file: "prowler/providers/linode/**"
|
||||
- any-glob-to-any-file: "tests/providers/linode/**"
|
||||
|
||||
provider/stackit:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/stackit/**"
|
||||
- any-glob-to-any-file: "tests/providers/stackit/**"
|
||||
|
||||
github_actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ".github/workflows/*"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
const fs = require('fs');
|
||||
|
||||
// Configuration from environment variables
|
||||
const REPORT_FILE = process.env.GRYPE_REPORT_FILE || 'grype-report.json';
|
||||
const IMAGE_NAME = process.env.IMAGE_NAME || 'container-image';
|
||||
const GITHUB_SHA = process.env.GITHUB_SHA || 'unknown';
|
||||
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || '';
|
||||
const GITHUB_RUN_ID = process.env.GITHUB_RUN_ID || '';
|
||||
const CUTOFF = process.env.CUTOFF || 'high';
|
||||
|
||||
// A cutoff of 'critical' blocks only on critical; anything else blocks on high and above
|
||||
const blocking = CUTOFF === 'critical' ? ['Critical'] : ['Critical', 'High'];
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf-8'));
|
||||
const matches = Array.isArray(report.matches) ? report.matches : [];
|
||||
const ignored = Array.isArray(report.ignoredMatches) ? report.ignoredMatches : [];
|
||||
|
||||
const counts = { Critical: 0, High: 0, Medium: 0, Low: 0, Negligible: 0, Unknown: 0 };
|
||||
const blockers = new Map();
|
||||
|
||||
for (const match of matches) {
|
||||
const severity = match.vulnerability.severity;
|
||||
if (counts[severity] !== undefined) {
|
||||
counts[severity]++;
|
||||
}
|
||||
if (blocking.includes(severity)) {
|
||||
const artifact = match.artifact;
|
||||
const fixedIn = (match.vulnerability.fix && match.vulnerability.fix.versions || []).join(', ');
|
||||
// Same CVE can match several install paths of one package; collapse them
|
||||
blockers.set(`${match.vulnerability.id}|${artifact.name}`, {
|
||||
id: match.vulnerability.id,
|
||||
severity,
|
||||
name: artifact.name,
|
||||
version: artifact.version,
|
||||
fixedIn
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ignoredBlocking = ignored.filter(m => blocking.includes(m.vulnerability.severity)).length;
|
||||
const shortSha = GITHUB_SHA.substring(0, 7);
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
|
||||
const severityConfig = {
|
||||
Critical: { icon: '🔴', label: 'Critical' },
|
||||
High: { icon: '🟠', label: 'High' },
|
||||
Medium: { icon: '🟡', label: 'Medium' },
|
||||
Low: { icon: '🔵', label: 'Low' }
|
||||
};
|
||||
|
||||
let comment = '## 🔎 Container Security Scan (Grype)\n\n';
|
||||
comment += `**Image:** \`${IMAGE_NAME}:${shortSha}\`\n`;
|
||||
comment += `**Last scan:** ${timestamp}\n\n`;
|
||||
|
||||
if (blockers.size === 0) {
|
||||
comment += '### ✅ Nothing Blocking\n\n';
|
||||
comment += `No findings at **${blocking.join(' or ').toLowerCase()}** severity.\n`;
|
||||
} else {
|
||||
comment += `### ⚠️ ${blockers.size} Finding(s) Blocking This PR\n\n`;
|
||||
comment += '| Severity | CVE | Package | Installed | Fixed in |\n';
|
||||
comment += '|---|---|---|---|---|\n';
|
||||
|
||||
const order = { Critical: 0, High: 1 };
|
||||
const rows = [...blockers.values()].sort((a, b) =>
|
||||
(order[a.severity] - order[b.severity]) || a.name.localeCompare(b.name));
|
||||
|
||||
for (const row of rows) {
|
||||
const config = severityConfig[row.severity];
|
||||
comment += `| ${config.icon} ${config.label} | \`${row.id}\` | \`${row.name}\` | ${row.version} | ${row.fixedIn || '—'} |\n`;
|
||||
}
|
||||
|
||||
comment += '\n**What to do:**\n';
|
||||
comment += '- Upgrade the package to the version in the "Fixed in" column.\n';
|
||||
comment += '- If it is pinned by another dependency, or the fix is otherwise out of reach, add it to `.grype.yaml` **with the reason**.\n';
|
||||
comment += '- Findings with no published fix never appear here: the scan runs with `only-fixed`, so it reports only what can actually be acted on.\n';
|
||||
}
|
||||
|
||||
const otherCounts = Object.entries(counts)
|
||||
.filter(([severity, count]) => !blocking.includes(severity) && count > 0)
|
||||
.map(([severity, count]) => `${severity.toLowerCase()}: ${count}`);
|
||||
|
||||
if (otherCounts.length > 0) {
|
||||
comment += `\nNot blocking at this cutoff — ${otherCounts.join(', ')}.\n`;
|
||||
}
|
||||
|
||||
if (ignoredBlocking > 0) {
|
||||
comment += `\n${ignoredBlocking} finding(s) excluded by \`.grype.yaml\`, each with a documented reason.\n`;
|
||||
}
|
||||
|
||||
comment += '\n---\n';
|
||||
comment += '📋 **Resources:**\n';
|
||||
|
||||
if (GITHUB_REPOSITORY && GITHUB_RUN_ID) {
|
||||
comment += `- [Download full report](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) (see artifacts)\n`;
|
||||
}
|
||||
|
||||
comment += '- [View in Security tab](https://github.com/' + (GITHUB_REPOSITORY || 'repository') + '/security/code-scanning)\n';
|
||||
comment += '- Scanned with [Grype](https://github.com/anchore/grype), alongside Trivy\n';
|
||||
|
||||
module.exports = comment;
|
||||
@@ -51,7 +51,8 @@ STDERR="$(mktemp)"
|
||||
trap 'rm -f "${STDERR}"' EXIT
|
||||
|
||||
set +e
|
||||
OUTPUT="$(osv-scanner scan source "${SCAN_ARGS[@]}" --format=json "$@" 2>"${STDERR}")"
|
||||
# ${a[@]+...} guard: an empty array trips `set -u` on bash before 4.4.
|
||||
OUTPUT="$(osv-scanner scan source ${SCAN_ARGS[@]+"${SCAN_ARGS[@]}"} --format=json "$@" 2>"${STDERR}")"
|
||||
RC=$?
|
||||
set -e
|
||||
|
||||
@@ -100,6 +101,8 @@ FINDINGS="$(printf '%s' "${OUTPUT}" | jq --argjson sevs "${SEVERITY_JSON}" '
|
||||
]
|
||||
')"
|
||||
|
||||
# jq exits 0 with no output on empty stdin, but non-zero on malformed JSON.
|
||||
# Let the failure abort under set -e rather than reporting zero findings.
|
||||
COUNT="$(printf '%s' "${FINDINGS}" | jq 'length')"
|
||||
|
||||
# Write the findings JSON to OSV_REPORT_FILE so callers (e.g. the composite
|
||||
@@ -108,7 +111,7 @@ if [ -n "${OSV_REPORT_FILE:-}" ]; then
|
||||
printf '%s' "${FINDINGS}" > "${OSV_REPORT_FILE}"
|
||||
fi
|
||||
|
||||
if [ "${COUNT}" -gt 0 ]; then
|
||||
if [ "${COUNT:-0}" -gt 0 ]; then
|
||||
echo "osv-scanner: ${COUNT} finding(s) at severity ${SEVERITY_LEVELS}"
|
||||
printf '%s' "${FINDINGS}" | jq -r '
|
||||
.[] | " [\(.severity)\(if .score then " \(.score)" else "" end)] \(.id) \(.ecosystem)/\(.package)@\(.version) — \(.summary // "(no summary)")"
|
||||
|
||||
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
|
||||
|
||||
### Available Templates
|
||||
|
||||
**Container Releases**
|
||||
#### Container Releases
|
||||
- `container-release-started.json`: Simple one-line notification when container push starts
|
||||
- `container-release-completed.json`: Simple one-line notification when container release completes
|
||||
|
||||
**Deployments**
|
||||
#### Deployments
|
||||
- `deployment-started.json`: Deployment start notification with Block Kit formatting
|
||||
- `deployment-completed.json`: Deployment completion notification (updates the start message)
|
||||
|
||||
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
|
||||
### Container Release (Simple One-Line)
|
||||
|
||||
**Start message:**
|
||||
```
|
||||
```text
|
||||
API container release 4.5.0 push started... View run
|
||||
```
|
||||
|
||||
**Completion message (success):**
|
||||
```
|
||||
```text
|
||||
[✓] API container release 4.5.0 push completed successfully! View run
|
||||
```
|
||||
|
||||
**Completion message (failure):**
|
||||
```
|
||||
```text
|
||||
[✗] API container release 4.5.0 push failed View run
|
||||
```
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: api-serializers
|
||||
@@ -275,6 +276,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: api-filters
|
||||
@@ -432,6 +434,14 @@ modules:
|
||||
e2e:
|
||||
- ui/tests/lighthouse/**
|
||||
|
||||
- name: ui-navigation
|
||||
match:
|
||||
- ui/components/layout/**
|
||||
- ui/tests/navigation/**
|
||||
tests: []
|
||||
e2e:
|
||||
- ui/tests/navigation/**
|
||||
|
||||
- name: ui-overview
|
||||
match:
|
||||
- ui/components/overview/**
|
||||
@@ -441,6 +451,17 @@ modules:
|
||||
e2e:
|
||||
- ui/tests/home/**
|
||||
|
||||
- name: ui-registry
|
||||
match:
|
||||
- ui/actions/registry/**
|
||||
- ui/app/**/registry/**
|
||||
- ui/components/registry/**
|
||||
- ui/lib/registry/**
|
||||
- ui/tests/registry/**
|
||||
tests: []
|
||||
e2e:
|
||||
- ui/tests/registry/**
|
||||
|
||||
- name: ui-shadcn
|
||||
match:
|
||||
- ui/components/shadcn/**
|
||||
@@ -464,6 +485,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: ui-attack-paths
|
||||
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
release-assets.githubusercontent.com:443
|
||||
api.github.com:443
|
||||
raw.githubusercontent.com:443
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
created: ${{ steps.set-short-sha.outputs.created }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -52,7 +53,9 @@ jobs:
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -159,9 +162,20 @@ jobs:
|
||||
with:
|
||||
context: ${{ env.WORKING_DIRECTORY }}
|
||||
push: true
|
||||
sbom: true
|
||||
# max, not the default min: min records little beyond the build ref.
|
||||
provenance: mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Prowler Local Server API
|
||||
org.opencontainers.image.description=API for Prowler Local Server (Django/DRF)
|
||||
org.opencontainers.image.vendor=ProwlerPro, Inc.
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
|
||||
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
@@ -179,12 +193,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
@@ -196,9 +210,9 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
|
||||
env:
|
||||
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
|
||||
|
||||
@@ -206,10 +220,10 @@ jobs:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
|
||||
env:
|
||||
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
|
||||
|
||||
@@ -249,9 +263,9 @@ jobs:
|
||||
id: outcome
|
||||
run: |
|
||||
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
|
||||
echo "outcome=success" >> $GITHUB_OUTPUT
|
||||
echo "outcome=success" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "outcome=failure" >> $GITHUB_OUTPUT
|
||||
echo "outcome=failure" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
|
||||
|
||||
@@ -81,6 +81,11 @@ jobs:
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
tuf-repo-cdn.sigstore.dev:443
|
||||
raw.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
grype.anchore.io:443
|
||||
get.anchore.io:443
|
||||
debian.map.fastlydns.net:80
|
||||
release-assets.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
@@ -105,7 +110,13 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: api/**
|
||||
files: |
|
||||
api/**
|
||||
.trivyignore.yaml
|
||||
.github/actions/trivy-scan/**
|
||||
.github/actions/grype-scan/**
|
||||
.grype.yaml
|
||||
.github/scripts/grype-pr-comment.js
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
@@ -113,6 +124,15 @@ jobs:
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
# api-container-build-push.yml resolves the SDK pin to the branch tip
|
||||
# before building, so match it here and scan what ships. Push only: PRs
|
||||
# stay deterministic against the committed lock.
|
||||
- name: Refresh prowler SDK pin to current branch tip
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && github.event_name == 'push'
|
||||
run: |
|
||||
pip install --no-cache-dir "uv==0.11.14"
|
||||
(cd api && uv lock --upgrade-package prowler)
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
@@ -134,5 +154,13 @@ jobs:
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-critical: 'true'
|
||||
severity: 'CRITICAL'
|
||||
fail-on-severity: 'high'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Scan container with Grype
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/grype-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-severity: 'high'
|
||||
@@ -85,6 +85,7 @@ jobs:
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
release-assets.githubusercontent.com:443
|
||||
cli.codecov.io:443
|
||||
keybase.io:443
|
||||
raw.githubusercontent.com:443
|
||||
@@ -107,6 +108,7 @@ jobs:
|
||||
files: |
|
||||
api/**
|
||||
.github/workflows/api-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Check labels
|
||||
id: label_check
|
||||
uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
|
||||
uses: agilepathway/label-checker@c324842522fbd012e4f590afe3b4e591301322ed # v1.6.66
|
||||
with:
|
||||
allow_failure: true
|
||||
prefix_mode: true
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: 'CI: Actionlint'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
schedule:
|
||||
- cron: '45 06 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
name: GitHub Actions Schema Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
auth.docker.io:443
|
||||
registry-1.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
# Always runs so it always reports; the lint is skipped when nothing changed.
|
||||
- name: Check for workflow changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: .github/**
|
||||
|
||||
# SC2129 is style only: it suggests grouping consecutive redirects.
|
||||
- name: Run actionlint
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
env:
|
||||
SHELLCHECK_OPTS: '-e SC2129'
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" --workdir /repo -e SHELLCHECK_OPTS \
|
||||
rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 \
|
||||
-color
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
echo "Removing 'status/awaiting-response' label from #$ISSUE_NUMBER"
|
||||
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response \
|
||||
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response" \
|
||||
-X DELETE
|
||||
|
||||
- name: Add 'status/waiting-for-revision' label
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
echo "Adding 'status/waiting-for-revision' label to #$ISSUE_NUMBER"
|
||||
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels \
|
||||
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels" \
|
||||
-X POST \
|
||||
-f labels[]='status/waiting-for-revision'
|
||||
@@ -61,6 +61,7 @@ jobs:
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
objects.githubusercontent.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
name: 'Tools: Sync Docker Hub Descriptions'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
paths:
|
||||
- 'docs/dockerhub/README.md'
|
||||
- '.github/workflows/dockerhub-descriptions.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
OVERVIEW_FILE: docs/dockerhub/README.md
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
prowlercloud:
|
||||
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- repository: prowlercloud/prowler
|
||||
short_description: 'Prowler CLI: the Open Cloud Security tool for AWS, Azure, Google Cloud, Kubernetes, M365 and GitHub'
|
||||
- repository: prowlercloud/prowler-api
|
||||
short_description: 'Prowler Local Server - API: the JSON API and Task Runner components of Prowler'
|
||||
- repository: prowlercloud/prowler-ui
|
||||
short_description: 'Prowler Local Server - UI: the web interface to run Prowler scans and explore findings'
|
||||
- repository: prowlercloud/prowler-mcp
|
||||
short_description: 'Prowler MCP: the interface for agents, including IDE plugins and agent integrations'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
hub.docker.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update Docker Hub description for ${{ matrix.repository }}
|
||||
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: ${{ matrix.repository }}
|
||||
short-description: ${{ matrix.short_description }}
|
||||
readme-filepath: ${{ env.OVERVIEW_FILE }}
|
||||
|
||||
toniblyx:
|
||||
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
hub.docker.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update Docker Hub description for toniblyx/prowler
|
||||
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
|
||||
with:
|
||||
username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }}
|
||||
repository: toniblyx/prowler
|
||||
short-description: 'Prowler CLI (legacy repository, mirrors prowlercloud/prowler)'
|
||||
readme-filepath: ${{ env.OVERVIEW_FILE }}
|
||||
@@ -38,13 +38,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# PRs only need the diff range; push to master/release walks the new range from event.before.
|
||||
# 50 is enough headroom for the longest realistic PR/push chain without paying for a full clone.
|
||||
fetch-depth: 50
|
||||
# Full history: a shallow clone cuts the PR's ancestry, git can no longer exclude commits
|
||||
# already on the base branch, and TruffleHog scans the cut-off commit as a root (the whole
|
||||
# tree as added). PRs that merge master in hit this and fail on pre-existing content.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Scan diff for secrets with TruffleHog
|
||||
# Action auto-injects --since-commit/--branch from event payload; passing them in extra_args produces duplicate flags.
|
||||
uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3
|
||||
uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2
|
||||
with:
|
||||
# The action runs the `latest` image unless told otherwise: pin the scanner to the action's release.
|
||||
version: 3.99.2@sha256:47a84bc18a0d04a165498bbbd3bacfd84176661cbc91f8e0f85467f6a771e99a
|
||||
extra_args: --results=verified,unknown
|
||||
@@ -38,16 +38,19 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
|
||||
- name: Set appVersion from release tag
|
||||
- name: Set chart version and appVersion from release tag
|
||||
run: |
|
||||
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
echo "Setting appVersion to ${RELEASE_TAG}"
|
||||
sed -i "s/^appVersion:.*/appVersion: \"${RELEASE_TAG}\"/" ${{ env.CHART_PATH }}/Chart.yaml
|
||||
# Strip any leading "v" so the chart version is valid SemVer 2.
|
||||
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME#v}"
|
||||
echo "Setting chart version and appVersion to ${RELEASE_TAG}"
|
||||
# Publish an immutable chart version per release instead of the static
|
||||
# 0.0.1 in source, so every release is a distinct, addressable artifact.
|
||||
yq -i ".version = \"${RELEASE_TAG}\" | .appVersion = \"${RELEASE_TAG}\"" ${{ env.CHART_PATH }}/Chart.yaml
|
||||
env:
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Login to GHCR
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${GITHUB_ACTOR} --password-stdin
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
|
||||
|
||||
- name: Update chart dependencies
|
||||
run: helm dependency update ${{ env.CHART_PATH }}
|
||||
|
||||
@@ -85,10 +85,10 @@ jobs:
|
||||
|
||||
# Check if author is in the org members list
|
||||
if printf '%s\n' "${ORG_MEMBERS[@]}" | grep -q "^${AUTHOR}$"; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
echo "is_member=true" >> "$GITHUB_OUTPUT"
|
||||
echo "$AUTHOR is an organization member"
|
||||
else
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
echo "is_member=false" >> "$GITHUB_OUTPUT"
|
||||
echo "$AUTHOR is not an organization member"
|
||||
fi
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ jobs:
|
||||
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
|
||||
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
|
||||
# disables postinstall scripts on transitives the same way.
|
||||
#
|
||||
# Files come from `git ls-files` because markdownlint doesn't traverse
|
||||
# dot-directories, so `.github/**/*.md` went unlinted.
|
||||
# `.markdownlintignore` still applies to the listed paths.
|
||||
env:
|
||||
pnpm_config_ignore_scripts: 'true'
|
||||
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
|
||||
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
created: ${{ steps.set-short-sha.outputs.created }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -51,7 +52,9 @@ jobs:
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -110,15 +113,17 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
dualstack.j.sni.global.fastly.net:443
|
||||
files.pythonhosted.org:443
|
||||
ghcr.io:443
|
||||
github.com:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
ghcr.io:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
files.pythonhosted.org:443
|
||||
pypi.org:443
|
||||
registry-1.docker.io:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -141,17 +146,20 @@ jobs:
|
||||
with:
|
||||
context: ${{ env.WORKING_DIRECTORY }}
|
||||
push: true
|
||||
sbom: true
|
||||
# max, not the default min: min records little beyond the build ref.
|
||||
provenance: mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Prowler MCP Server
|
||||
org.opencontainers.image.title=Prowler MCP
|
||||
org.opencontainers.image.description=Model Context Protocol server for Prowler
|
||||
org.opencontainers.image.vendor=ProwlerPro, Inc.
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ github.event_name == 'release' && github.event.release.published_at || github.event.head_commit.timestamp }}
|
||||
${{ github.event_name == 'release' && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
|
||||
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
@@ -169,11 +177,11 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
- name: Login to DockerHub
|
||||
@@ -187,9 +195,9 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
|
||||
env:
|
||||
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
|
||||
|
||||
@@ -197,10 +205,10 @@ jobs:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
|
||||
env:
|
||||
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
|
||||
|
||||
@@ -240,9 +248,9 @@ jobs:
|
||||
id: outcome
|
||||
run: |
|
||||
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
|
||||
echo "outcome=success" >> $GITHUB_OUTPUT
|
||||
echo "outcome=success" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "outcome=failure" >> $GITHUB_OUTPUT
|
||||
echo "outcome=failure" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
|
||||
|
||||
@@ -77,16 +77,22 @@ jobs:
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
tuf-repo-cdn.sigstore.dev:443
|
||||
ghcr.io:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
files.pythonhosted.org:443
|
||||
pypi.org:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
dualstack.j.sni.global.fastly.net:443
|
||||
api.github.com:443
|
||||
mirror.gcr.io:443
|
||||
check.trivy.dev:443
|
||||
get.trivy.dev:443
|
||||
release-assets.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
raw.githubusercontent.com:443
|
||||
grype.anchore.io:443
|
||||
get.anchore.io:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -98,7 +104,13 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: mcp_server/**
|
||||
files: |
|
||||
mcp_server/**
|
||||
.trivyignore.yaml
|
||||
.github/actions/trivy-scan/**
|
||||
.github/actions/grype-scan/**
|
||||
.grype.yaml
|
||||
.github/scripts/grype-pr-comment.js
|
||||
files_ignore: |
|
||||
mcp_server/README.md
|
||||
mcp_server/CHANGELOG.md
|
||||
@@ -125,5 +137,13 @@ jobs:
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-critical: 'true'
|
||||
severity: 'CRITICAL'
|
||||
fail-on-severity: 'high'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Scan MCP container with Grype
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/grype-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-severity: 'high'
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Publish prowler-mcp package to PyPI
|
||||
if: steps.pypi-check.outputs.skip != 'true'
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
|
||||
print-hash: true
|
||||
@@ -0,0 +1,100 @@
|
||||
name: 'MCP: Tests'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
MCP_WORKING_DIR: ./mcp_server
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
mcp-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
# requires-python is >=3.12 while the shipped image is 3.13; testing both
|
||||
# is what keeps that floor honest.
|
||||
python-version:
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./mcp_server
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
# hub.prowler.com and raw.githubusercontent.com are deliberately absent:
|
||||
# the suite mocks every outbound call, so a real one must fail the job.
|
||||
# The sentry.io entry is not the test suite: the Codecov uploader sends
|
||||
# its own telemetry there, so api-tests.yml and sdk-tests.yml allow it too.
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
release-assets.githubusercontent.com:443
|
||||
cli.codecov.io:443
|
||||
keybase.io:443
|
||||
ingest.codecov.io:443
|
||||
o26192.ingest.us.sentry.io:443
|
||||
storage.googleapis.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for MCP server changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
mcp_server/**
|
||||
.github/workflows/mcp-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
mcp_server/README.md
|
||||
mcp_server/CHANGELOG.md
|
||||
mcp_server/changelog.d/**
|
||||
mcp_server/AGENTS.md
|
||||
mcp_server/Dockerfile
|
||||
mcp_server/.dockerignore
|
||||
mcp_server/entrypoint.sh
|
||||
|
||||
- name: Setup Python with uv
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-python-uv
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
working-directory: ./mcp_server
|
||||
|
||||
- name: Run tests with pytest
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: uv run pytest --cov=./prowler_mcp_server --cov-report=xml tests
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: mcp
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
objects.githubusercontent.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
@@ -129,7 +130,6 @@ jobs:
|
||||
handwritten_changelogs=""
|
||||
|
||||
all_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n')
|
||||
added=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" | tr ' ' '\n')
|
||||
added_or_renamed=$(printf '%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
|
||||
added_modified_or_renamed=$(printf '%s\n%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
done
|
||||
|
||||
if [ -n "$found_in" ]; then
|
||||
found_in=$(echo "$found_in" | sed 's/, $//')
|
||||
found_in="${found_in%, }"
|
||||
MAPPED="${MAPPED}- \`${check_id}\` (\`${provider}\`): ${found_in}"$'\n'
|
||||
else
|
||||
UNMAPPED="${UNMAPPED}- \`${check_id}\` (\`${provider}\`)"$'\n'
|
||||
|
||||
@@ -74,15 +74,15 @@ jobs:
|
||||
done <<< "$STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES"
|
||||
|
||||
if [ "$HAS_CONFLICTS" = true ]; then
|
||||
echo "has_conflicts=true" >> $GITHUB_OUTPUT
|
||||
echo "has_conflicts=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "conflict_files<<EOF"
|
||||
echo "$CONFLICT_FILES"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Conflict markers detected"
|
||||
else
|
||||
echo "has_conflicts=false" >> $GITHUB_OUTPUT
|
||||
echo "has_conflicts=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No conflict markers found in changed files"
|
||||
fi
|
||||
env:
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
id: vars
|
||||
run: |
|
||||
SHORT_SHA="${GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA}"
|
||||
echo "short_sha=${SHORT_SHA::7}" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=${SHORT_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
|
||||
@@ -46,6 +46,7 @@ jobs:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
repository: ${{ secrets.CLOUD_DISPATCH }}
|
||||
event-type: prowler-pull-request-merged
|
||||
# repository_dispatch caps client_payload at 10 properties; this is exactly at the cap.
|
||||
client-payload: |
|
||||
{
|
||||
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
|
||||
@@ -54,8 +55,8 @@ jobs:
|
||||
"PROWLER_PR_TITLE": ${{ toJson(github.event.pull_request.title) }},
|
||||
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
|
||||
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }},
|
||||
"PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }},
|
||||
"PROWLER_PR_MERGED_BY": "${{ github.event.pull_request.merged_by.login }}",
|
||||
"PROWLER_PR_BASE_BRANCH": ${{ toJson(github.event.pull_request.base.ref) }},
|
||||
"PROWLER_PR_HEAD_BRANCH": ${{ toJson(github.event.pull_request.head.ref) }}
|
||||
"PROWLER_PR_STACK_NUMBER": "${{ github.event.pull_request.stack.number }}",
|
||||
"PROWLER_PR_STACK_POSITION": "${{ github.event.pull_request.stack.position }}",
|
||||
"PROWLER_PR_STACK_SIZE": "${{ github.event.pull_request.stack.size }}"
|
||||
}
|
||||
@@ -25,6 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -33,6 +34,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Enable release freeze
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -70,13 +77,15 @@ jobs:
|
||||
|
||||
echo "Prowler version: $PROWLER_VERSION"
|
||||
echo "Branch name: $BRANCH_NAME"
|
||||
echo "Is minor release: $([ $PATCH_VERSION -eq 0 ] && echo 'true' || echo 'false')"
|
||||
echo "Is minor release: $([ "$PATCH_VERSION" -eq 0 ] && echo 'true' || echo 'false')"
|
||||
else
|
||||
echo "Invalid version syntax: '$PROWLER_VERSION' (must be N.N.N)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout release branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
echo "Checking out branch $BRANCH_NAME for release $PROWLER_VERSION..."
|
||||
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
|
||||
@@ -85,8 +94,33 @@ jobs:
|
||||
elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then
|
||||
echo "Branch $BRANCH_NAME exists remotely, checking out..."
|
||||
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
|
||||
elif [ "$PATCH_VERSION" -eq 0 ]; then
|
||||
# Only cut the branch once the compiled changelogs are on master
|
||||
MASTER_SHA=$(git rev-parse origin/master)
|
||||
VERSION_REGEX="${PROWLER_VERSION//./\\.}"
|
||||
FOUND_CHANGELOG=false
|
||||
for changelog in prowler/CHANGELOG.md api/CHANGELOG.md ui/CHANGELOG.md mcp_server/CHANGELOG.md; do
|
||||
if git show "origin/master:$changelog" 2>/dev/null | grep -qE "^## \[.*\(Prowler v?${VERSION_REGEX}\)"; then
|
||||
FOUND_CHANGELOG=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$FOUND_CHANGELOG" != "true" ]; then
|
||||
echo "ERROR: No changelog entry for Prowler $PROWLER_VERSION on master. Merge the compiled changelogs PR first."
|
||||
exit 1
|
||||
fi
|
||||
# Check the SDK version on master before creating the remote branch
|
||||
if ! git show "origin/master:pyproject.toml" | grep -qE "^version = \"${VERSION_REGEX}\"$" \
|
||||
|| ! git show "origin/master:prowler/config/config.py" | grep -qE "^prowler_version = \"${VERSION_REGEX}\"$"; then
|
||||
echo "ERROR: pyproject.toml or prowler/config/config.py on master is not at version $PROWLER_VERSION. Fix master before creating $BRANCH_NAME."
|
||||
exit 1
|
||||
fi
|
||||
echo "Branch $BRANCH_NAME does not exist, creating it from master ($MASTER_SHA)..."
|
||||
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/heads/${BRANCH_NAME}" -f sha="${MASTER_SHA}"
|
||||
git update-ref "refs/remotes/origin/${BRANCH_NAME}" "$MASTER_SHA"
|
||||
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
|
||||
else
|
||||
echo "ERROR: Branch $BRANCH_NAME does not exist. For minor releases (X.Y.0), create it manually first. For patch releases (X.Y.Z), the branch should already exist."
|
||||
echo "ERROR: Branch $BRANCH_NAME does not exist. For patch releases (X.Y.Z), the branch should already exist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -100,7 +134,8 @@ jobs:
|
||||
if [ -f "$changelog_file" ]; then
|
||||
# Extract version that matches this Prowler release
|
||||
# Format: ## [version] (Prowler X.Y.Z) or ## [vversion] (Prowler vX.Y.Z)
|
||||
local version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
|
||||
local version
|
||||
version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
|
||||
echo "$version"
|
||||
else
|
||||
echo ""
|
||||
@@ -171,55 +206,55 @@ jobs:
|
||||
|
||||
# Determine if components have changes for this specific release
|
||||
if [ -n "$SDK_VERSION" ]; then
|
||||
echo "HAS_SDK_CHANGES=true" >> $GITHUB_ENV
|
||||
echo "HAS_SDK_CHANGES=true" >> "$GITHUB_ENV"
|
||||
HAS_SDK_CHANGES="true"
|
||||
echo "✓ SDK changes detected - version: $SDK_VERSION"
|
||||
extract_changelog "prowler/CHANGELOG.md" "$SDK_VERSION" "prowler_changelog.md"
|
||||
else
|
||||
echo "HAS_SDK_CHANGES=false" >> $GITHUB_ENV
|
||||
echo "HAS_SDK_CHANGES=false" >> "$GITHUB_ENV"
|
||||
HAS_SDK_CHANGES="false"
|
||||
echo "ℹ No SDK changes for this release"
|
||||
touch "prowler_changelog.md"
|
||||
fi
|
||||
|
||||
if [ -n "$API_VERSION" ]; then
|
||||
echo "HAS_API_CHANGES=true" >> $GITHUB_ENV
|
||||
echo "HAS_API_CHANGES=true" >> "$GITHUB_ENV"
|
||||
HAS_API_CHANGES="true"
|
||||
echo "✓ API changes detected - version: $API_VERSION"
|
||||
extract_changelog "api/CHANGELOG.md" "$API_VERSION" "api_changelog.md"
|
||||
else
|
||||
echo "HAS_API_CHANGES=false" >> $GITHUB_ENV
|
||||
echo "HAS_API_CHANGES=false" >> "$GITHUB_ENV"
|
||||
HAS_API_CHANGES="false"
|
||||
echo "ℹ No API changes for this release"
|
||||
touch "api_changelog.md"
|
||||
fi
|
||||
|
||||
if [ -n "$UI_VERSION" ]; then
|
||||
echo "HAS_UI_CHANGES=true" >> $GITHUB_ENV
|
||||
echo "HAS_UI_CHANGES=true" >> "$GITHUB_ENV"
|
||||
HAS_UI_CHANGES="true"
|
||||
echo "✓ UI changes detected - version: $UI_VERSION"
|
||||
extract_changelog "ui/CHANGELOG.md" "$UI_VERSION" "ui_changelog.md"
|
||||
else
|
||||
echo "HAS_UI_CHANGES=false" >> $GITHUB_ENV
|
||||
echo "HAS_UI_CHANGES=false" >> "$GITHUB_ENV"
|
||||
HAS_UI_CHANGES="false"
|
||||
echo "ℹ No UI changes for this release"
|
||||
touch "ui_changelog.md"
|
||||
fi
|
||||
|
||||
if [ -n "$MCP_VERSION" ]; then
|
||||
echo "HAS_MCP_CHANGES=true" >> $GITHUB_ENV
|
||||
echo "HAS_MCP_CHANGES=true" >> "$GITHUB_ENV"
|
||||
HAS_MCP_CHANGES="true"
|
||||
echo "✓ MCP changes detected - version: $MCP_VERSION"
|
||||
extract_changelog "mcp_server/CHANGELOG.md" "$MCP_VERSION" "mcp_changelog.md"
|
||||
else
|
||||
echo "HAS_MCP_CHANGES=false" >> $GITHUB_ENV
|
||||
echo "HAS_MCP_CHANGES=false" >> "$GITHUB_ENV"
|
||||
HAS_MCP_CHANGES="false"
|
||||
echo "ℹ No MCP changes for this release"
|
||||
touch "mcp_changelog.md"
|
||||
fi
|
||||
|
||||
# Combine changelogs in order: UI, API, SDK, MCP
|
||||
> combined_changelog.md
|
||||
: > combined_changelog.md
|
||||
|
||||
if [ "$HAS_UI_CHANGES" = "true" ] && [ -s "ui_changelog.md" ]; then
|
||||
echo "## UI" >> combined_changelog.md
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: 'Tools: Release Freeze Gate'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- checks_requested
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release-freeze-gate:
|
||||
name: release-freeze-gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check release freeze status
|
||||
env:
|
||||
RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }}
|
||||
run: |
|
||||
case "${RELEASE_FREEZE}" in
|
||||
true|TRUE|True)
|
||||
echo "::error::Release freeze is active. Merges to master are temporarily blocked."
|
||||
echo "Set the RELEASE_FREEZE repository variable to false when the release is complete."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Release freeze is not active."
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,27 @@
|
||||
name: 'Tools: Release Freeze Lift'
|
||||
|
||||
run-name: 'Lift release freeze after Prowler ${{ github.event.release.tag_name }}'
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- 'published'
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release-freeze-lift:
|
||||
if: github.repository == 'prowler-cloud/prowler' && !github.event.release.prerelease
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Disable release freeze
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
gh variable set RELEASE_FREEZE --body false --repo "${GITHUB_REPOSITORY}"
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
prowler_version: ${{ steps.get-prowler-version.outputs.prowler_version }}
|
||||
created: ${{ steps.get-prowler-version.outputs.created }}
|
||||
latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }}
|
||||
stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }}
|
||||
permissions:
|
||||
@@ -64,9 +65,9 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -86,6 +87,7 @@ jobs:
|
||||
fi
|
||||
echo "latest_tag=latest" >> "${GITHUB_OUTPUT}"
|
||||
echo "stable_tag=stable" >> "${GITHUB_OUTPUT}"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -146,24 +148,24 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
_http._tcp.deb.debian.org:443
|
||||
aka.ms:443
|
||||
api.ecr-public.us-east-1.amazonaws.com:443
|
||||
public.ecr.aws:443
|
||||
sts.amazonaws.com:443
|
||||
sts.us-east-1.amazonaws.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
cdn.powershellgallery.com:443
|
||||
debian.map.fastlydns.net:80
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
auth.docker.io:443
|
||||
debian.map.fastlydns.net:80
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
public.ecr.aws:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
sts.amazonaws.com:443
|
||||
sts.us-east-1.amazonaws.com:443
|
||||
www.powershellgallery.com:443
|
||||
aka.ms:443
|
||||
cdn.powershellgallery.com:443
|
||||
_http._tcp.deb.debian.org:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -198,9 +200,20 @@ jobs:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE_PATH }}
|
||||
push: true
|
||||
sbom: true
|
||||
# max, not the default min: min records little beyond the build ref.
|
||||
provenance: mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Prowler CLI
|
||||
org.opencontainers.image.description=Open Source security tool for cloud security assessments, audits, incident response, continuous monitoring, hardening and forensics readiness
|
||||
org.opencontainers.image.vendor=ProwlerPro, Inc.
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
|
||||
org.opencontainers.image.version=${{ needs.setup.outputs.prowler_version }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
@@ -219,14 +232,14 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
registry-1.docker.io:443
|
||||
api.ecr-public.us-east-1.amazonaws.com:443
|
||||
auth.docker.io:443
|
||||
public.ecr.aws:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
github.com:443
|
||||
public.ecr.aws:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
api.ecr-public.us-east-1.amazonaws.com:443
|
||||
sts.amazonaws.com:443
|
||||
sts.us-east-1.amazonaws.com:443
|
||||
|
||||
@@ -252,10 +265,10 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
|
||||
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
|
||||
env:
|
||||
NEEDS_SETUP_OUTPUTS_LATEST_TAG: ${{ needs.setup.outputs.latest_tag }}
|
||||
|
||||
@@ -263,12 +276,12 @@ jobs:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
|
||||
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
|
||||
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
|
||||
env:
|
||||
NEEDS_SETUP_OUTPUTS_PROWLER_VERSION: ${{ needs.setup.outputs.prowler_version }}
|
||||
NEEDS_SETUP_OUTPUTS_STABLE_TAG: ${{ needs.setup.outputs.stable_tag }}
|
||||
@@ -293,7 +306,7 @@ jobs:
|
||||
if: needs.setup.outputs.latest_tag == 'latest' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
|
||||
-t "${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
|
||||
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable
|
||||
env:
|
||||
@@ -343,9 +356,9 @@ jobs:
|
||||
id: outcome
|
||||
run: |
|
||||
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
|
||||
echo "outcome=success" >> $GITHUB_OUTPUT
|
||||
echo "outcome=success" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "outcome=failure" >> $GITHUB_OUTPUT
|
||||
echo "outcome=failure" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'Dockerfile*'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.trivyignore.yaml'
|
||||
- '.github/workflows/sdk-container-checks.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -80,9 +81,14 @@ jobs:
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
tuf-repo-cdn.sigstore.dev:443
|
||||
api.github.com:443
|
||||
mirror.gcr.io:443
|
||||
check.trivy.dev:443
|
||||
raw.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
grype.anchore.io:443
|
||||
get.anchore.io:443
|
||||
debian.map.fastlydns.net:80
|
||||
release-assets.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
@@ -112,7 +118,12 @@ jobs:
|
||||
Dockerfile*
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
.trivyignore.yaml
|
||||
.github/workflows/sdk-container-checks.yml
|
||||
.github/actions/trivy-scan/**
|
||||
.github/actions/grype-scan/**
|
||||
.grype.yaml
|
||||
.github/scripts/grype-pr-comment.js
|
||||
files_ignore: |
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
@@ -139,5 +150,13 @@ jobs:
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-critical: 'true'
|
||||
severity: 'CRITICAL'
|
||||
fail-on-severity: 'high'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Scan SDK container with Grype
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/grype-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-severity: 'high'
|
||||
@@ -0,0 +1,196 @@
|
||||
name: 'SDK: Package Checks'
|
||||
|
||||
# Rehearses the PyPI release on every packaging change and once a week, from the
|
||||
# consumer's side. Two incidents this guards against:
|
||||
#
|
||||
# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while
|
||||
# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid
|
||||
# the conflict inside the repo; pip could not install the wheel and silently
|
||||
# resolved `pip install prowler` to 5.37.1 for a week.
|
||||
# - 5.39.0 never published: an unpinned build backend started emitting core metadata
|
||||
# 2.5 and the twine bundled in the publish action rejected it.
|
||||
#
|
||||
# Both were only detectable at release time because nothing built and installed the
|
||||
# artifact earlier. The weekly run also catches releases yanked from PyPI after we
|
||||
# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
schedule:
|
||||
# Monday 06:00 UTC. Yanks and upstream releases happen without a commit here.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
# Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by
|
||||
# sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag).
|
||||
# A metadata check that passes here must pass there.
|
||||
TWINE_VERSION: '7.0.0'
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
# Scheduled and manual runs always execute; pushes and PRs only when a packaging
|
||||
# input changed. Jobs skipped this way still report success to branch protection.
|
||||
run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Detect packaging changes
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
id: filter
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
README.md
|
||||
util/replicate_pypi_package.py
|
||||
util/check_yanked_pins.py
|
||||
api/pyproject.toml
|
||||
api/uv.lock
|
||||
mcp_server/pyproject.toml
|
||||
mcp_server/uv.lock
|
||||
.github/workflows/sdk-package-checks.yml
|
||||
.github/workflows/sdk-pypi-release.yml
|
||||
.github/actions/setup-python-uv/**
|
||||
|
||||
install-from-wheel:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.10'
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
package:
|
||||
- 'prowler'
|
||||
include:
|
||||
# prowler-cloud is the same tree renamed by util/replicate_pypi_package.py;
|
||||
# one Python is enough to prove the rename and its build still work.
|
||||
- python-version: '3.12'
|
||||
package: 'prowler-cloud'
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python with uv
|
||||
uses: ./.github/actions/setup-python-uv
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
install-dependencies: 'false'
|
||||
|
||||
- name: Rename package to prowler-cloud
|
||||
if: matrix.package == 'prowler-cloud'
|
||||
run: |
|
||||
pip install --no-cache-dir toml
|
||||
python util/replicate_pypi_package.py
|
||||
|
||||
- name: Build sdist and wheel
|
||||
run: uv build
|
||||
|
||||
- name: Check metadata with the release workflow's twine
|
||||
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
|
||||
|
||||
- name: Install the wheel with pip into a clean virtualenv
|
||||
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
|
||||
# override-dependencies or constraint-dependencies, so neither does this step.
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Smoke test the installed CLI
|
||||
run: |
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/prowler" --version
|
||||
# Loads every AWS check module from the installed wheel: catches files missing
|
||||
# from the package. grep fails the step if the summary line never appears.
|
||||
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
|
||||
|
||||
pinned-releases-not-yanked:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Check every pinned and locked release against PyPI
|
||||
run: python util/check_yanked_pins.py . api mcp_server
|
||||
@@ -84,8 +84,18 @@ jobs:
|
||||
- name: Build Prowler package
|
||||
run: uv build
|
||||
|
||||
- name: Verify the wheel installs with pip
|
||||
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
|
||||
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
|
||||
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Publish Prowler package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
print-hash: true
|
||||
|
||||
@@ -128,7 +138,17 @@ jobs:
|
||||
- name: Build prowler-cloud package
|
||||
run: uv build
|
||||
|
||||
- name: Verify the wheel installs with pip
|
||||
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
|
||||
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
|
||||
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Publish prowler-cloud package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
print-hash: true
|
||||
@@ -44,7 +44,10 @@ jobs:
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install boto3
|
||||
# Pinned to the versions in pyproject.toml: the ISO partitions region
|
||||
# data comes from the endpoints.json bundled with botocore, so the
|
||||
# botocore version is itself a data source and must be deterministic
|
||||
run: pip install boto3==1.40.61 botocore==1.40.61
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
|
||||
### Changes
|
||||
|
||||
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
|
||||
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`).
|
||||
|
||||
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
|
||||
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
|
||||
|
||||
@@ -216,7 +216,8 @@ jobs:
|
||||
elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then
|
||||
echo "No AWS service paths detected; skipping AWS tests."
|
||||
else
|
||||
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
|
||||
read -ra service_paths <<< "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}"
|
||||
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml "${service_paths[@]}"
|
||||
fi
|
||||
env:
|
||||
STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }}
|
||||
@@ -639,6 +640,30 @@ jobs:
|
||||
flags: prowler-py${{ matrix.python-version }}-e2enetworks
|
||||
files: ./e2enetworks_coverage.xml
|
||||
|
||||
# Huawei Cloud Provider
|
||||
- name: Check if Huawei Cloud files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-huaweicloud
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/huaweicloud/**
|
||||
./tests/**/huaweicloud/**
|
||||
./uv.lock
|
||||
|
||||
- name: Run Huawei Cloud tests
|
||||
if: steps.changed-huaweicloud.outputs.any_changed == 'true'
|
||||
run: uv run pytest -n auto --cov=./prowler/providers/huaweicloud --cov-report=xml:huaweicloud_coverage.xml tests/providers/huaweicloud
|
||||
|
||||
- name: Upload Huawei Cloud coverage to Codecov
|
||||
if: steps.changed-huaweicloud.outputs.any_changed == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: prowler-py${{ matrix.python-version }}-huaweicloud
|
||||
files: ./huaweicloud_coverage.xml
|
||||
|
||||
# External Provider (dynamic loading)
|
||||
- name: Check if External Provider files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -57,6 +57,7 @@ jobs:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
@@ -84,7 +85,8 @@ jobs:
|
||||
echo "Changed files:"
|
||||
echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n'
|
||||
echo ""
|
||||
python .github/scripts/test-impact.py ${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}
|
||||
read -ra changed <<< "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}"
|
||||
python .github/scripts/test-impact.py "${changed[@]}"
|
||||
env:
|
||||
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
|
||||
@@ -92,21 +94,21 @@ jobs:
|
||||
id: set-flags
|
||||
run: |
|
||||
if [[ -n "${STEPS_IMPACT_OUTPUTS_SDK_TESTS}" ]]; then
|
||||
echo "has-sdk-tests=true" >> $GITHUB_OUTPUT
|
||||
echo "has-sdk-tests=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-sdk-tests=false" >> $GITHUB_OUTPUT
|
||||
echo "has-sdk-tests=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [[ -n "${STEPS_IMPACT_OUTPUTS_API_TESTS}" ]]; then
|
||||
echo "has-api-tests=true" >> $GITHUB_OUTPUT
|
||||
echo "has-api-tests=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-api-tests=false" >> $GITHUB_OUTPUT
|
||||
echo "has-api-tests=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [[ -n "${STEPS_IMPACT_OUTPUTS_UI_E2E}" ]]; then
|
||||
echo "has-ui-e2e=true" >> $GITHUB_OUTPUT
|
||||
echo "has-ui-e2e=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-ui-e2e=false" >> $GITHUB_OUTPUT
|
||||
echo "has-ui-e2e=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
STEPS_IMPACT_OUTPUTS_SDK_TESTS: ${{ steps.impact.outputs.sdk-tests }}
|
||||
@@ -115,22 +117,22 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Test Impact Analysis" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [[ "${STEPS_IMPACT_OUTPUTS_RUN_ALL}" == "true" ]]; then
|
||||
echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🚨 **Critical path changed - running ALL tests**" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Affected Modules" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Tests to Run" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Category | Paths |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
env:
|
||||
|
||||
@@ -41,17 +41,20 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
created: ${{ steps.set-short-sha.outputs.created }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
egress-policy: block
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -111,15 +114,15 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
registry-1.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
auth.docker.io:443
|
||||
registry.npmjs.org:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
fonts.googleapis.com:443
|
||||
fonts.gstatic.com:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
registry-1.docker.io:443
|
||||
registry.npmjs.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -144,9 +147,20 @@ jobs:
|
||||
build-args: |
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }}
|
||||
push: true
|
||||
sbom: true
|
||||
# max, not the default min: min records little beyond the build ref.
|
||||
provenance: mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Prowler Local Server UI
|
||||
org.opencontainers.image.description=Web UI for Prowler Local Server (Next.js)
|
||||
org.opencontainers.image.vendor=ProwlerPro, Inc.
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
|
||||
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
@@ -164,12 +178,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
@@ -182,9 +196,9 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
|
||||
env:
|
||||
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
|
||||
|
||||
@@ -192,10 +206,10 @@ jobs:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
|
||||
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
|
||||
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
|
||||
env:
|
||||
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
|
||||
|
||||
@@ -235,9 +249,9 @@ jobs:
|
||||
id: outcome
|
||||
run: |
|
||||
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
|
||||
echo "outcome=success" >> $GITHUB_OUTPUT
|
||||
echo "outcome=success" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "outcome=failure" >> $GITHUB_OUTPUT
|
||||
echo "outcome=failure" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
|
||||
|
||||
@@ -78,6 +78,7 @@ jobs:
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
tuf-repo-cdn.sigstore.dev:443
|
||||
registry.npmjs.org:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
fonts.googleapis.com:443
|
||||
@@ -88,6 +89,9 @@ jobs:
|
||||
get.trivy.dev:443
|
||||
release-assets.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
raw.githubusercontent.com:443
|
||||
grype.anchore.io:443
|
||||
get.anchore.io:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -99,7 +103,13 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: ui/**
|
||||
files: |
|
||||
ui/**
|
||||
.trivyignore.yaml
|
||||
.github/actions/trivy-scan/**
|
||||
.github/actions/grype-scan/**
|
||||
.grype.yaml
|
||||
.github/scripts/grype-pr-comment.js
|
||||
files_ignore: |
|
||||
ui/CHANGELOG.md
|
||||
ui/changelog.d/**
|
||||
@@ -130,5 +140,13 @@ jobs:
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-critical: 'true'
|
||||
severity: 'CRITICAL'
|
||||
fail-on-severity: 'high'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Scan UI container with Grype
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/grype-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-severity: 'high'
|
||||
@@ -10,12 +10,13 @@ on:
|
||||
- master
|
||||
- "v5.*"
|
||||
paths:
|
||||
- '.github/workflows/ui-e2e-tests-v2.yml'
|
||||
- '.github/test-impact.yml'
|
||||
- 'ui/**'
|
||||
- 'api/**' # API changes can affect UI E2E
|
||||
- '!ui/CHANGELOG.md'
|
||||
- '!api/CHANGELOG.md'
|
||||
- ".github/workflows/ui-e2e-tests-v2.yml"
|
||||
- ".github/workflows/test-impact-analysis.yml"
|
||||
- ".github/test-impact.yml"
|
||||
- "ui/**"
|
||||
- "api/**" # API changes can affect UI E2E
|
||||
- "!ui/CHANGELOG.md"
|
||||
- "!api/CHANGELOG.md"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -36,14 +37,18 @@ jobs:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
# ubuntu-latest is moving to 26.04 (rollout began October 19, 2026; completion
|
||||
# targeted for November 19, 2026), where `playwright install` refuses to install
|
||||
# Chromium for the pinned Playwright. Stay on 24.04 until the suite upgrades.
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
AUTH_SECRET: 'fallback-ci-secret-for-testing'
|
||||
AUTH_SECRET: "fallback-ci-secret-for-testing"
|
||||
AUTH_TRUST_HOST: true
|
||||
NEXTAUTH_URL: 'http://localhost:3000'
|
||||
AUTH_URL: 'http://localhost:3000'
|
||||
UI_API_BASE_URL: 'http://localhost:8080/api/v1'
|
||||
NEXTAUTH_URL: "http://localhost:3000"
|
||||
AUTH_URL: "http://localhost:3000"
|
||||
UI_API_BASE_URL: "http://localhost:8080/api/v1"
|
||||
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
|
||||
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
|
||||
@@ -59,7 +64,7 @@ jobs:
|
||||
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
|
||||
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
|
||||
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
|
||||
E2E_KUBERNETES_CONTEXT: 'kind-kind'
|
||||
E2E_KUBERNETES_CONTEXT: "kind-kind"
|
||||
E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config
|
||||
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }}
|
||||
E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }}
|
||||
@@ -107,17 +112,115 @@ jobs:
|
||||
|
||||
- name: Show test scope
|
||||
run: |
|
||||
echo "## E2E Test Scope" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## E2E Test Scope" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
|
||||
echo "Running **ALL** E2E tests (critical path changed)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Running **ALL** E2E tests (critical path changed)" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo ""
|
||||
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
env:
|
||||
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
|
||||
|
||||
- name: Validate E2E prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
declare -A required=()
|
||||
|
||||
suite_selected() {
|
||||
[[ "${RUN_ALL_TESTS}" == "true" ]] ||
|
||||
[[ " ${E2E_TEST_PATHS} " == *"ui/tests/$1/"* ]]
|
||||
}
|
||||
|
||||
require_vars() {
|
||||
local variable
|
||||
for variable in "$@"; do
|
||||
required["${variable}"]=1
|
||||
done
|
||||
}
|
||||
|
||||
if suite_selected auth || suite_selected providers ||
|
||||
suite_selected invitations || suite_selected scans ||
|
||||
suite_selected navigation; then
|
||||
require_vars E2E_ADMIN_USER E2E_ADMIN_PASSWORD
|
||||
fi
|
||||
|
||||
if suite_selected sign-up; then
|
||||
require_vars E2E_NEW_USER_PASSWORD
|
||||
fi
|
||||
|
||||
if suite_selected invitations; then
|
||||
require_vars E2E_NEW_USER_PASSWORD E2E_ORGANIZATION_ID
|
||||
fi
|
||||
|
||||
if suite_selected scans; then
|
||||
require_vars \
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID \
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY \
|
||||
E2E_AWS_PROVIDER_SECRET_KEY
|
||||
fi
|
||||
|
||||
if suite_selected providers; then
|
||||
require_vars \
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID \
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY \
|
||||
E2E_AWS_PROVIDER_SECRET_KEY \
|
||||
E2E_AWS_PROVIDER_ROLE_ARN \
|
||||
E2E_AZURE_SUBSCRIPTION_ID \
|
||||
E2E_AZURE_CLIENT_ID \
|
||||
E2E_AZURE_SECRET_ID \
|
||||
E2E_AZURE_TENANT_ID \
|
||||
E2E_M365_DOMAIN_ID \
|
||||
E2E_M365_CLIENT_ID \
|
||||
E2E_M365_SECRET_ID \
|
||||
E2E_M365_TENANT_ID \
|
||||
E2E_M365_CERTIFICATE_CONTENT \
|
||||
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY \
|
||||
E2E_GCP_PROJECT_ID \
|
||||
E2E_GITHUB_APP_ID \
|
||||
E2E_GITHUB_BASE64_APP_PRIVATE_KEY \
|
||||
E2E_GITHUB_USERNAME \
|
||||
E2E_GITHUB_PERSONAL_ACCESS_TOKEN \
|
||||
E2E_GITHUB_ORGANIZATION \
|
||||
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN \
|
||||
E2E_OCI_TENANCY_ID \
|
||||
E2E_OCI_USER_ID \
|
||||
E2E_OCI_FINGERPRINT \
|
||||
E2E_OCI_KEY_CONTENT \
|
||||
E2E_ALIBABACLOUD_ACCOUNT_ID \
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_ID \
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET \
|
||||
E2E_ALIBABACLOUD_ROLE_ARN \
|
||||
E2E_OKTA_DOMAIN \
|
||||
E2E_OKTA_CLIENT_ID \
|
||||
E2E_OKTA_BASE64_PRIVATE_KEY \
|
||||
E2E_GOOGLEWORKSPACE_CUSTOMER_ID \
|
||||
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON \
|
||||
E2E_GOOGLEWORKSPACE_DELEGATED_USER \
|
||||
E2E_VERCEL_TEAM_ID \
|
||||
E2E_VERCEL_API_TOKEN
|
||||
fi
|
||||
|
||||
missing=()
|
||||
if (( ${#required[@]} > 0 )); then
|
||||
while IFS= read -r variable; do
|
||||
[[ -z "${!variable:-}" ]] && missing+=("${variable}")
|
||||
done < <(printf '%s\n' "${!required[@]}" | sort)
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "Missing required E2E variables:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
{
|
||||
echo "## Missing E2E prerequisites"
|
||||
printf -- "- \`%s\`\n" "${missing[@]}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "E2E prerequisite preflight passed."
|
||||
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1
|
||||
with:
|
||||
@@ -134,12 +237,12 @@ jobs:
|
||||
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
|
||||
|
||||
- name: Fix API data directory permissions
|
||||
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
|
||||
run: docker run --rm -v "$(pwd)/_data/api:/data" alpine chown -R 1000:1000 /data
|
||||
|
||||
- name: Add AWS credentials for testing
|
||||
run: |
|
||||
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
|
||||
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
|
||||
echo "AWS_ACCESS_KEY_ID=${E2E_AWS_PROVIDER_ACCESS_KEY}" >> .env
|
||||
echo "AWS_SECRET_ACCESS_KEY=${E2E_AWS_PROVIDER_SECRET_KEY}" >> .env
|
||||
|
||||
- name: Build API image from current code
|
||||
# docker-compose.yml references prowlercloud/prowler-api:latest from the registry,
|
||||
@@ -168,7 +271,7 @@ jobs:
|
||||
timeout=150
|
||||
elapsed=0
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then
|
||||
if curl -s "${UI_API_BASE_URL}/docs" >/dev/null 2>&1; then
|
||||
echo "Prowler API is ready!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -193,7 +296,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: 'ui/.nvmrc'
|
||||
node-version-file: "ui/.nvmrc"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
@@ -202,7 +305,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup pnpm and Next.js cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -238,59 +341,65 @@ jobs:
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm run test:e2e:install
|
||||
|
||||
- name: Run E2E tests
|
||||
- name: Run standard E2E tests
|
||||
id: standard-e2e
|
||||
working-directory: ./ui
|
||||
run: |
|
||||
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
|
||||
echo "Running ALL E2E tests..."
|
||||
echo "Running all standard E2E tests..."
|
||||
pnpm run test:e2e
|
||||
else
|
||||
echo "Running targeted E2E tests: ${E2E_TEST_PATHS}"
|
||||
# Convert glob patterns to playwright test paths
|
||||
# e.g., "ui/tests/providers/**" -> "tests/providers"
|
||||
echo "Running targeted standard E2E tests: ${E2E_TEST_PATHS}"
|
||||
TEST_PATHS="${E2E_TEST_PATHS}"
|
||||
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u)
|
||||
# Drop auth setup helpers (not runnable test suites)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
|
||||
# Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**),
|
||||
# expand to specific subdirs to avoid Playwright discovering setup files
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vE '^tests/(setups|registry)/' || true)
|
||||
|
||||
if echo "$TEST_PATHS" | grep -qx 'tests/'; then
|
||||
echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..."
|
||||
SPECIFIC_DIRS=""
|
||||
for dir in tests/*/; do
|
||||
[[ "$dir" == "tests/setups/" ]] && continue
|
||||
[[ "$dir" == "tests/setups/" || "$dir" == "tests/registry/" ]] && continue
|
||||
SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n'
|
||||
done
|
||||
# Replace "tests/" with specific dirs, keep other paths
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/')
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true)
|
||||
TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}"
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u)
|
||||
fi
|
||||
if [[ -z "$TEST_PATHS" ]]; then
|
||||
echo "No runnable E2E test paths after filtering setups"
|
||||
exit 0
|
||||
fi
|
||||
# Filter out directories that don't contain any test files
|
||||
|
||||
VALID_PATHS=""
|
||||
while IFS= read -r p; do
|
||||
[[ -z "$p" ]] && continue
|
||||
if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
|
||||
VALID_PATHS="${VALID_PATHS}${p}"$'\n'
|
||||
while IFS= read -r path; do
|
||||
[[ -z "$path" ]] && continue
|
||||
if find "$path" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
|
||||
VALID_PATHS="${VALID_PATHS}${path}"$'\n'
|
||||
else
|
||||
echo "Skipping empty test directory: $p"
|
||||
echo "Skipping empty test directory: $path"
|
||||
fi
|
||||
done <<< "$TEST_PATHS"
|
||||
VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true)
|
||||
if [[ -z "$VALID_PATHS" ]]; then
|
||||
echo "No test files found in any resolved paths — skipping E2E"
|
||||
exit 0
|
||||
|
||||
if [[ -n "$VALID_PATHS" ]]; then
|
||||
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
|
||||
echo "Resolved standard test paths: $TEST_PATHS"
|
||||
read -ra test_paths <<< "$TEST_PATHS"
|
||||
pnpm exec playwright test "${test_paths[@]}"
|
||||
else
|
||||
echo "No standard E2E test paths selected."
|
||||
fi
|
||||
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
|
||||
echo "Resolved test paths: $TEST_PATHS"
|
||||
pnpm exec playwright test $TEST_PATHS
|
||||
fi
|
||||
|
||||
- name: Run Registry fixture E2E tests
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(steps.standard-e2e.outcome == 'success' || steps.standard-e2e.outcome == 'failure') &&
|
||||
(env.RUN_ALL_TESTS == 'true' || contains(format(' {0} ', env.E2E_TEST_PATHS), ' ui/tests/registry/'))
|
||||
working-directory: ./ui
|
||||
run: pnpm run test:e2e:registry
|
||||
|
||||
# A worker that crashes on import or a connection test that never resolves
|
||||
# only shows up here; Playwright just reports a missing footer button.
|
||||
- name: Dump API service logs
|
||||
if: failure()
|
||||
run: docker compose logs --no-color --tail=300 api worker worker-beat || true
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: failure()
|
||||
@@ -304,6 +413,29 @@ jobs:
|
||||
run: |
|
||||
docker compose down -v || true
|
||||
|
||||
# Fork pull requests cannot access the secrets required by the E2E suites.
|
||||
fork-e2e-unavailable:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == true &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Report unavailable E2E tests
|
||||
run: |
|
||||
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "UI E2E tests require repository secrets and cannot run for fork pull requests." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Skip job - provides clear feedback when no E2E tests needed
|
||||
skip-e2e:
|
||||
needs: impact-analysis
|
||||
@@ -322,12 +454,12 @@ jobs:
|
||||
|
||||
- name: No E2E tests needed
|
||||
run: |
|
||||
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No UI E2E tests needed for this change." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> $GITHUB_STEP_SUMMARY
|
||||
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "No UI E2E tests needed for this change." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> "$GITHUB_STEP_SUMMARY"
|
||||
env:
|
||||
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
|
||||
@@ -21,7 +21,10 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
ui-tests:
|
||||
runs-on: ubuntu-latest
|
||||
# ubuntu-latest is moving to 26.04 (rollout began October 19, 2026; completion
|
||||
# targeted for November 19, 2026), where `playwright install` refuses to install
|
||||
# Chromium for the pinned Playwright. Stay on 24.04 until the suite upgrades.
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -113,7 +116,7 @@ jobs:
|
||||
- name: Get pnpm store directory
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup pnpm and Next.js cache
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
@@ -136,10 +139,6 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run healthcheck
|
||||
|
||||
- name: Check product-tour alignment
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run tour:check
|
||||
|
||||
- name: Run pnpm audit
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run audit
|
||||
@@ -157,7 +156,8 @@ jobs:
|
||||
echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}"
|
||||
# Convert space-separated to vitest related format (remove ui/ prefix for relative paths)
|
||||
CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ')
|
||||
pnpm exec vitest related $CHANGED_FILES --run --project unit
|
||||
read -ra changed <<< "$CHANGED_FILES"
|
||||
pnpm exec vitest related "${changed[@]}" --run --project unit
|
||||
env:
|
||||
STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }}
|
||||
|
||||
@@ -181,9 +181,9 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Run browser tests
|
||||
- name: Run integration tests
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run test:browser
|
||||
run: pnpm run test:integration
|
||||
|
||||
- name: Build application
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -173,3 +173,5 @@ GEMINI.md
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose-dev.override.yml
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# Findings excluded from the Grype gate, each with a reason.
|
||||
# Anything not listed here blocks the pull request at critical or high severity.
|
||||
# Pairs are explicit: a new CVE against an already-listed package still blocks.
|
||||
#
|
||||
# Every entry below has a published fix we cannot take. Findings with no fix at all are
|
||||
# not listed: the scan runs with only-fixed, so they never reach the gate.
|
||||
|
||||
ignore:
|
||||
|
||||
# Shipped inside the PowerShell tarball, in its bundled MicrosoftTeams module.
|
||||
# Not a dependency we declare, and not one we can upgrade independently.
|
||||
- vulnerability: CVE-2026-26127
|
||||
package:
|
||||
name: Microsoft.Bcl.Memory
|
||||
|
||||
# Documented in .trivyignore.yaml: ships inside ExchangeOnlineManagement
|
||||
# 3.10.1, the latest gallery release. Re-check on the next EXO release.
|
||||
- vulnerability: CVE-2026-84008
|
||||
package:
|
||||
name: System.Security.Cryptography.Pkcs
|
||||
version: 10.0.0
|
||||
|
||||
# The .NET runtime bundled inside the PowerShell tarball the Dockerfile pins.
|
||||
# CVE-2026-62901 is the same temporary exception documented in .trivyignore.yaml:
|
||||
# fixed in .NET 9.0.19 / 10.0.11 (2026-08-11), but no published PowerShell release
|
||||
# ships a patched runtime yet (7.5.9 bundles 9.0.18; 7.6.4 bundles 10.0.x < 10.0.11).
|
||||
# pwsh runs only local M365 module cmdlets; nothing listens for inbound WebSocket
|
||||
# connections. Remove with the Trivy exception by 2026-09-15.
|
||||
- vulnerability: CVE-2026-62901
|
||||
package:
|
||||
name: Microsoft.NETCore.App.Runtime.linux-x64
|
||||
- vulnerability: CVE-2026-62901
|
||||
package:
|
||||
name: Microsoft.NETCore.App.Runtime.linux-arm64
|
||||
|
||||
|
||||
# The CPython interpreter, compiled into the official base image.
|
||||
# TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and
|
||||
# that is a runtime upgrade pending its own evaluation. The remaining three need 3.15 and
|
||||
# are unfixable either way -- the MCP image already runs 3.13.14 and still reports them.
|
||||
- vulnerability: CVE-2026-11940
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-11972
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-15308
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-3298
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-3644
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-4224
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-4786
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-6100
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-7210
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-9669
|
||||
package:
|
||||
name: python
|
||||
# CVE-2026-82049 (tarfile data/tar filter bypass via a hard link to a symlink) has no
|
||||
# fixed CPython release on any branch: the fix is merged on main and 3.13 only, and the
|
||||
# 3.12 backport is still open. Grype records 3.14.0b1 as the fix, so only-fixed does not
|
||||
# drop it, yet python:3.12.14-slim-trixie reports it too. Prowler never extracts tar
|
||||
# archives to disk: the ECR image inspection reads members in memory with extractfile().
|
||||
# Remove once the base image ships a 3.12 release that includes the backport.
|
||||
# https://github.com/python/cpython/issues/157190
|
||||
# https://github.com/python/cpython/pull/157454
|
||||
- vulnerability: CVE-2026-82049
|
||||
package:
|
||||
name: python
|
||||
|
||||
# zlib in the Alpine base image of the MCP (python:3.13.16-alpine3.23) container.
|
||||
# CVE-2026-85091 is a heap overflow in gz_vacate() reached only through non-blocking
|
||||
# gzwrite() followed by gzprintf() after a write stall; the MCP server never writes
|
||||
# gzip files, so the image never calls that path. The UI image no longer needs this:
|
||||
# node:24.22.1-alpine is built on Alpine 3.24 and ships 1.3.2-r1. TEMPORARY: the fix
|
||||
# landed in the 3.23 index on 2026-10-06, two days after the pinned python image was
|
||||
# built, and could be taken with `apk add --upgrade`, but we wait for the base image
|
||||
# to ship it instead (python:3.13.17-alpine3.23, built 2026-10-22, already does).
|
||||
# Pinned to that version so the rule stops matching on its own once the base image
|
||||
# moves forward. Remove when the MCP digest is bumped, by 2026-11-07.
|
||||
# https://security.alpinelinux.org/vuln/CVE-2026-85091
|
||||
- vulnerability: CVE-2026-85091
|
||||
package:
|
||||
name: zlib
|
||||
version: 1.3.2-r0
|
||||
|
||||
# Documented in .trivyignore.yaml: go-jose 4.1.5 compiled into the Trivy binary (0.76.1,
|
||||
# the latest release). Pinned to 4.1.5 so it stops matching with the next Trivy bump.
|
||||
- vulnerability: CVE-2026-116587
|
||||
package:
|
||||
name: github.com/go-jose/go-jose/v4
|
||||
version: v4.1.5
|
||||
@@ -47,6 +47,14 @@ repos:
|
||||
priority: 20
|
||||
|
||||
## GITHUB ACTIONS
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: v1.7.12
|
||||
hooks:
|
||||
- id: actionlint
|
||||
# SC2129 only suggests grouping consecutive redirects; not worth restructuring for.
|
||||
args: ['-shellcheck=-e SC2129']
|
||||
priority: 30
|
||||
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.24.1
|
||||
hooks:
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
# Trivy ignore file for prowlercloud/prowler SDK container image.
|
||||
# Each entry below documents (a) the affected package and why it ships in the
|
||||
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
|
||||
# upstream fix status. Entries carry an expiry so they auto-force re-review.
|
||||
# Entries are scoped per-package so suppressions cannot drift onto unrelated
|
||||
# packages that may be assigned the same CVE in the future.
|
||||
#
|
||||
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
|
||||
|
||||
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
|
||||
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
|
||||
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
|
||||
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
|
||||
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
|
||||
# is available yet.
|
||||
CVE-2026-42496 pkg:perl exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-13221 - Perl regex trie overflow.
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression
|
||||
# was introduced after this version. Debian currently marks bookworm as
|
||||
# vulnerable, which causes Trivy to report a false positive.
|
||||
# Ref: https://github.com/Perl/perl5/issues/23388
|
||||
CVE-2026-13221 pkg:perl exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2025-7458 — SQLite integer overflow.
|
||||
# Package: libsqlite3-0.
|
||||
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
|
||||
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
|
||||
# internal and bounded. No Debian bookworm fix is available.
|
||||
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
|
||||
|
||||
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
|
||||
# Package: linux-libc-dev.
|
||||
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
|
||||
# not a running kernel. Containers execute against the host kernel, so these
|
||||
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
|
||||
# has not been backported to Debian's 6.1 LTS line.
|
||||
CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
|
||||
|
||||
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
|
||||
# zipOpenNewFileInZip4_64.
|
||||
# Packages: zlib1g, zlib1g-dev.
|
||||
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
|
||||
# the published rationale "contrib/minizip not built and src:zlib not producing
|
||||
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
|
||||
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
|
||||
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
|
||||
# clear it fully.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
|
||||
CVE-2023-45853 pkg:zlib1g exp:2026-08-15
|
||||
CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15
|
||||
|
||||
# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to
|
||||
# an unchecked packet_length field in transport.c (heap corruption, possible RCE).
|
||||
# Package: libssh2-1.
|
||||
# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4
|
||||
# (installed in the SDK Dockerfile for the networking/PowerShell stack). The
|
||||
# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP
|
||||
# client parsing transport packets from a server. Prowler never uses libcurl's
|
||||
# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the
|
||||
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
|
||||
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
|
||||
CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15
|
||||
|
||||
# --- API container image (api/Dockerfile) ---
|
||||
# The entries below are specific to the Prowler API image, which ships
|
||||
# PowerShell and additional build tooling on top of the same bookworm base.
|
||||
|
||||
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
|
||||
# `xml.parsers.expat` and `xml.etree.ElementTree`.
|
||||
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
|
||||
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
|
||||
# `.venv`; the system `python3.11` is only present because `python3-dev` is
|
||||
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
|
||||
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
|
||||
# the affected interpreter, which Prowler does not do with the system Python.
|
||||
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
|
||||
CVE-2026-7210 pkg:python3.11 exp:2026-08-15
|
||||
CVE-2026-7210 pkg:python3.11-dev exp:2026-08-15
|
||||
CVE-2026-7210 pkg:python3.11-minimal exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11 exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15
|
||||
|
||||
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
|
||||
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
|
||||
# Package: libunbound8.
|
||||
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
|
||||
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
|
||||
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
|
||||
# that processes attacker-influenced DNS responses. Prowler never starts an
|
||||
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
|
||||
CVE-2026-33278 pkg:libunbound8 exp:2026-08-15
|
||||
CVE-2026-42960 pkg:libunbound8 exp:2026-08-15
|
||||
@@ -0,0 +1,236 @@
|
||||
# Trivy suppressions for the prowlercloud/prowler SDK and API container images.
|
||||
#
|
||||
# This file replaces the classic .trivyignore, which parsed only the CVE id: the
|
||||
# `pkg:` selector written on each line was documentation and the entry suppressed
|
||||
# its CVE across every package in the image. The `purls` field below is honoured,
|
||||
# so each entry is scoped to the package it names. Verified against Trivy 0.71.2:
|
||||
# an entry given the wrong purl leaves the finding reported, where the classic
|
||||
# format suppressed it.
|
||||
#
|
||||
# `expired_at` forces re-review. Keep the dates staggered.
|
||||
#
|
||||
# The four entries below are currently redundant: the scan runs with ignore-unfixed,
|
||||
# and none of them has a published fix, so they never reach the gate either way. They
|
||||
# are kept because the reasoning is what justifies accepting them, and because they
|
||||
# apply again the moment any of them gains a fix we do not take.
|
||||
#
|
||||
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
|
||||
# binary package built from that source, so perl-base is flagged for modules only
|
||||
# perl-modules-* ships. Neither image installs those, and nothing in either
|
||||
# invokes perl.
|
||||
#
|
||||
# Why these four are accepted rather than fixed (reviewed 2026-07-31):
|
||||
#
|
||||
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
|
||||
# Debian marks CVE-2026-42496 "fix_deferred" and the other three "affected".
|
||||
# A newer base image, apt upgrade, or a newer Debian release changes nothing.
|
||||
# 2. The package cannot be removed. "Essential: yes" means removal needs
|
||||
# dpkg --force-remove-essential, which breaks apt for anything built
|
||||
# downstream from these images.
|
||||
# 3. Changing base distribution was evaluated and rejected. Alpine drops perl
|
||||
# entirely, but PowerShell publishes no linux-musl-arm64 build in any
|
||||
# release, so M365 scanning would break on arm64 -- which is what we run in
|
||||
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
|
||||
# are a paid tier, so builds would not be reproducibly pinnable.
|
||||
#
|
||||
# Not-invoked claim verified by sweeping both images for files with a perl
|
||||
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
|
||||
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
|
||||
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
|
||||
# runtime, plus one build-time script inside the ExchangeOnlineManagement
|
||||
# PowerShell module that is never invoked.
|
||||
|
||||
vulnerabilities:
|
||||
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
|
||||
- id: CVE-2026-42496
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
|
||||
- id: CVE-2026-57433
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
|
||||
- id: CVE-2026-8376
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Regex trie bug giving silently wrong matches above 65535 alternation branches.
|
||||
# perl 5.40.1 is in range, so this rests on nothing invoking perl. Short expiry
|
||||
# to force a re-look. Ref: https://github.com/Perl/perl5/issues/23388
|
||||
- id: CVE-2026-13221
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2026-11-30
|
||||
|
||||
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
|
||||
# (Modules/MicrosoftTeams/8.0.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
|
||||
# SBOM and reports what it declares, which is not the same as what the image contains:
|
||||
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
|
||||
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
|
||||
# of it is a dependency we declare -- only Microsoft can change the module's contents.
|
||||
# proxy-addr 2.0.7 (CVE-2026-90711) is one more such declaration: no file named proxy-addr
|
||||
# exists in the image, only its purl inside that manifest. The UI lock lists proxy-addr 2.0.7
|
||||
# through express, but the Next standalone output does not ship it, so the shared entry
|
||||
# hides nothing real in the UI image either (checked on prowlercloud/prowler-ui:latest).
|
||||
# @modelcontextprotocol/sdk 1.27.1 (CVE-2026-104850) is another: only its purl is in that
|
||||
# manifest. The entry is pinned to 1.27.1 because the UI really depends on the SDK (1.26.0
|
||||
# via @langchain/mcp-adapters), and a version-less purl would hide that finding too.
|
||||
- id: CVE-2020-0606
|
||||
purls:
|
||||
- "pkg:nuget/Microsoft.WindowsDesktop.App.Ref"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2019-0820
|
||||
purls:
|
||||
- "pkg:nuget/System.Text.RegularExpressions"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-47302
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-47304
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50525
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50527
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50648
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
# Same manifest, pinned to 8.0.4: PowerShell's own copy is also flagged by these CVEs.
|
||||
- id: CVE-2026-96222
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-96958
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-96959
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-96960
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-13676
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-16221
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-18446
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-75899
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-75975
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-76172
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-75931
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-84292
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-69192
|
||||
purls:
|
||||
- "pkg:npm/ip-address"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-101898
|
||||
purls:
|
||||
- "pkg:npm/axios"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-101901
|
||||
purls:
|
||||
- "pkg:npm/axios"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-101903
|
||||
purls:
|
||||
- "pkg:npm/axios"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-101905
|
||||
purls:
|
||||
- "pkg:npm/axios"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-101906
|
||||
purls:
|
||||
- "pkg:npm/axios"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-101907
|
||||
purls:
|
||||
- "pkg:npm/axios"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-101909
|
||||
purls:
|
||||
- "pkg:npm/axios"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-90711
|
||||
purls:
|
||||
- "pkg:npm/proxy-addr"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-104850
|
||||
purls:
|
||||
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
|
||||
expired_at: 2027-01-31
|
||||
# Two more SDK declarations and follow-redirects 1.16.0 from the same manifest. The SDK
|
||||
# entries stay pinned to 1.27.1 for the reason above. No lock in this repo lists
|
||||
# follow-redirects, so its pin only keeps a future real dependency from being hidden.
|
||||
- id: CVE-2026-111767
|
||||
purls:
|
||||
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-111768
|
||||
purls:
|
||||
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-111755
|
||||
purls:
|
||||
- "pkg:npm/follow-redirects@1.16.0"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-111756
|
||||
purls:
|
||||
- "pkg:npm/follow-redirects@1.16.0"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Unlike the manifest entries above, this DLL ships in the image:
|
||||
# ExchangeOnlineManagement/3.10.1/netCore/System.Security.Cryptography.Pkcs.dll. It is a
|
||||
# DoS in CMS/PKCS parsing; Prowler only feeds it the tenant's own certificate. 3.10.1 is
|
||||
# the latest gallery release, so only Microsoft can update it. Re-check on the next EXO
|
||||
# release.
|
||||
- id: CVE-2026-84008
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Pkcs@10.0.0"
|
||||
expired_at: 2026-12-15
|
||||
|
||||
# go-jose 4.1.5 is compiled into the Trivy binary (usr/local/bin/trivy). Trivy merged the
|
||||
# 4.1.6 bump upstream (aquasecurity/trivy#11488) but 0.76.1 is still the latest release.
|
||||
# Bump TRIVY_VERSION and drop this entry once a release ships it.
|
||||
- id: CVE-2026-116587
|
||||
purls:
|
||||
- "pkg:golang/github.com/go-jose/go-jose/v4@v4.1.5"
|
||||
expired_at: 2026-11-20
|
||||
@@ -51,7 +51,6 @@ Use these skills for detailed patterns on-demand:
|
||||
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
|
||||
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
|
||||
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
|
||||
| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) |
|
||||
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
|
||||
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
|
||||
|
||||
@@ -62,18 +61,17 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
| Adding new providers | `prowler-provider` |
|
||||
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
|
||||
| Adding services to existing providers | `prowler-provider` |
|
||||
| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` |
|
||||
| After creating/modifying a skill | `skill-sync` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Changing button labels or section headings on a tour-covered page | `prowler-tour` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Configuring MCP servers in agentic workflows | `gh-aw` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
@@ -84,6 +82,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating a universal (multi-provider) compliance framework | `prowler-compliance` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
@@ -92,7 +91,6 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating/updating compliance frameworks | `prowler-compliance` |
|
||||
| Debug why a GitHub Actions job is failing | `prowler-ci` |
|
||||
| Debugging gh-aw compilation errors | `gh-aw` |
|
||||
| Editing a UI file containing data-tour-id attributes | `prowler-tour` |
|
||||
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
|
||||
@@ -109,8 +107,6 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
|
||||
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
|
||||
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
|
||||
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
|
||||
+55
-7
@@ -1,23 +1,47 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/prowler"
|
||||
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG POWERSHELL_VERSION=7.5.12
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
|
||||
ARG TRIVY_VERSION=0.71.2
|
||||
ARG TRIVY_VERSION=0.76.1
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
|
||||
ARG TRIVY_SHA256_AMD64=c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0
|
||||
ARG TRIVY_SHA256_ARM64=e174dff2a6dba5e3ae41f8d0537a6324405bd08132497340304c6b20e0f16a1f
|
||||
ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd
|
||||
ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
|
||||
|
||||
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
|
||||
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
|
||||
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
|
||||
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
|
||||
# gzip 1.13-1+deb13u1 CVE-2026-41992
|
||||
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
|
||||
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
|
||||
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
|
||||
# libbrotli1 1.1.0-2+deb13u1 CVE-2026-111497
|
||||
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
|
||||
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
|
||||
# openssl packages are flagged separately, so all are named.
|
||||
# Drop each one once the base image ships its fixed version.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
build-essential pkg-config libzstd-dev zlib1g-dev \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade \
|
||||
util-linux libssl3t64 openssl openssl-provider-legacy \
|
||||
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 libbrotli1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
@@ -29,6 +53,9 @@ RUN ARCH=$(uname -m) && \
|
||||
else \
|
||||
echo "Unsupported architecture: $ARCH" && exit 1 ; \
|
||||
fi && \
|
||||
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
|
||||
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
|
||||
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
|
||||
mkdir -p /opt/microsoft/powershell/7 && \
|
||||
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
|
||||
chmod +x /opt/microsoft/powershell/7/pwsh && \
|
||||
@@ -45,6 +72,9 @@ RUN ARCH=$(uname -m) && \
|
||||
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
|
||||
fi && \
|
||||
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
|
||||
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
|
||||
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
|
||||
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
|
||||
tar zxf /tmp/trivy.tar.gz -C /tmp && \
|
||||
mv /tmp/trivy /usr/local/bin/trivy && \
|
||||
chmod +x /usr/local/bin/trivy && \
|
||||
@@ -63,6 +93,9 @@ RUN ARCH=$(uname -m) && \
|
||||
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
|
||||
fi && \
|
||||
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
|
||||
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
|
||||
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
|
||||
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
|
||||
mkdir -p /tmp/zizmor-extract && \
|
||||
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
|
||||
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
|
||||
@@ -89,13 +122,15 @@ ENV HOME='/home/prowler'
|
||||
ENV PATH="${HOME}/.local/bin:${PATH}"
|
||||
#hadolint ignore=DL3013
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir uv==0.11.14
|
||||
pip install --no-cache-dir uv==0.13.4
|
||||
|
||||
RUN uv sync --locked --compile-bytecode && \
|
||||
rm -rf ~/.cache/uv
|
||||
|
||||
# Install PowerShell modules
|
||||
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py
|
||||
# Install PowerShell modules. Drop the MicrosoftTeams build SBOM: Trivy reports the npm
|
||||
# tooling it declares as installed, though none ships (see .trivyignore.yaml).
|
||||
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py && \
|
||||
rm -rf "${HOME}"/.local/share/powershell/Modules/MicrosoftTeams/*/_manifest
|
||||
|
||||
USER root
|
||||
|
||||
@@ -105,6 +140,9 @@ RUN apt-get purge -y --auto-remove \
|
||||
pkg-config \
|
||||
libzstd-dev \
|
||||
zlib1g-dev \
|
||||
wget \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER prowler
|
||||
@@ -113,5 +151,15 @@ USER prowler
|
||||
RUN pip uninstall dash-html-components -y && \
|
||||
pip uninstall dash-core-components -y
|
||||
|
||||
USER root
|
||||
|
||||
# pip is build-only; the entrypoint runs the venv directly.
|
||||
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
|
||||
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
|
||||
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
|
||||
|
||||
USER prowler
|
||||
ENTRYPOINT ["/home/prowler/.venv/bin/prowler"]
|
||||
@@ -34,6 +34,9 @@ test: ## Test with pytest
|
||||
rm -rf .coverage && \
|
||||
pytest -n auto -vvv -s --cov=./prowler --cov-report=xml tests
|
||||
|
||||
test-mcp: ## Test MCP server with pytest (mirrors CI)
|
||||
cd mcp_server && uv run pytest --cov=./prowler_mcp_server --cov-report=term-missing tests
|
||||
|
||||
coverage: ## Show Test Coverage
|
||||
coverage run --skip-covered -m pytest -v && \
|
||||
coverage report -m && \
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<b><i>Prowler</b> is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With thousands of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
|
||||
</p>
|
||||
<p align="center">
|
||||
<b>Secure ANY cloud at AI Speed at <a href="https://prowler.com">prowler.com</i></b>
|
||||
<b>The Agentic Cloud Defender</i></b>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://cloud.prowler.com/sign-up">Try Prowler Cloud</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -56,7 +59,7 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
|
||||
|
||||
## Prowler Cloud & Prowler Local Server
|
||||
|
||||
[Prowler Cloud](https://cloud.prowler.com/) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
|
||||
[Prowler Cloud](https://cloud.prowler.com/sign-up) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
|
||||
|
||||

|
||||

|
||||
@@ -123,27 +126,28 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
|
||||
| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI |
|
||||
| AWS | 662 | 86 | 50 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 191 | 22 | 25 | 16 | Official | UI, API, CLI |
|
||||
| GCP | 110 | 20 | 22 | 12 | Official | UI, API, CLI |
|
||||
| Kubernetes | 92 | 7 | 11 | 11 | Official | UI, API, CLI |
|
||||
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI |
|
||||
| M365 | 144 | 10 | 9 | 10 | Official | UI, API, CLI |
|
||||
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
|
||||
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
|
||||
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
|
||||
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| Google Workspace | 65 | 11 | 4 | 6 | Official | UI, API, CLI |
|
||||
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
|
||||
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
|
||||
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
|
||||
| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI |
|
||||
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
|
||||
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
|
||||
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
|
||||
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
|
||||
| StackIT [Contact us](https://prowler.com/contact) | 8 | 2 | 1 | 3 | Unofficial | CLI |
|
||||
| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI |
|
||||
|
||||
> [!Note]
|
||||
|
||||
@@ -59,5 +59,9 @@ DJANGO_GITHUB_OAUTH_CLIENT_ID=""
|
||||
DJANGO_GITHUB_OAUTH_CLIENT_SECRET=""
|
||||
DJANGO_GITHUB_OAUTH_CALLBACK_URL=""
|
||||
|
||||
# Public base URL of the Prowler UI, used to link Jira issues back to findings.
|
||||
# Leave empty to omit the link.
|
||||
DJANGO_UI_BASE_URL=""
|
||||
|
||||
# Deletion Task Batch Size
|
||||
DJANGO_DELETION_BATCH_SIZE=5000
|
||||
@@ -4,6 +4,279 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.48.0] (Prowler v5.47.0)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the API container image, with django-allauth from 65.15.0 to 65.19.7 because earlier releases cap oauthlib below 4, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure [(#13185)](https://github.com/prowler-cloud/prowler/pull/13185)
|
||||
|
||||
---
|
||||
|
||||
## [1.47.0] (Prowler v5.46.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Compliance report PDF cover table wraps every value, so long account IDs such as OCI tenancy OCIDs no longer overflow their cell [(#13063)](https://github.com/prowler-cloud/prowler/pull/13063)
|
||||
- Scan ingestion compares the serialized resource metadata with the stored value and serializes sets in a stable order, so unchanged resources no longer get their metadata, details and updated_at rewritten on every scan [(#13148)](https://github.com/prowler-cloud/prowler/pull/13148)
|
||||
- Report generation shares the parsed resource metadata between the findings of a batch and releases each batch before building the next one [(#13149)](https://github.com/prowler-cloud/prowler/pull/13149)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `GET /resources/metadata`, `GET /resources/metadata/latest`, `GET /findings/metadata` and `GET /findings/metadata/latest` now honour the provider groups of the caller role instead of aggregating every provider in the tenant [(#12964)](https://github.com/prowler-cloud/prowler/pull/12964)
|
||||
- Trivy from 0.75.0 to 0.76.0 in the API container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0 [(#13064)](https://github.com/prowler-cloud/prowler/pull/13064)
|
||||
- Pillow from 12.3.0 to 13.0.0, certifi to 2026.10.19, aiohttp to 3.14.5 with yarl 1.25.1, uv to 0.13.4 and Trivy to 0.76.1 in the API container image, patching the Pillow JPEG decoder heap overflow, the compromised emSign root CAs bundled in certifi and uv, the aiohttp resource exhaustion advisories and the Go 1.27.2 stdlib CVE in the Trivy binary [(#13107)](https://github.com/prowler-cloud/prowler/pull/13107)
|
||||
- Attack Paths custom queries strip block comments before the blocked-operation check and drop APOC virtual nodes and relationships from results, so `LOAD CSV` can no longer be split with a comment nor its response returned under a forged provider label [(#13120)](https://github.com/prowler-cloud/prowler/pull/13120)
|
||||
- SAML SSO configuration endpoints require the `manage_account` permission instead of `manage_integrations`, since the configured IdP controls who signs in and which role they receive [(#13121)](https://github.com/prowler-cloud/prowler/pull/13121)
|
||||
- djangorestframework from 3.15.2 to 3.18.3 in the API, patching the JSON parser charset and Accept header denial of service and the TokenProxy revocation bypass, and the API container image no longer ships the MicrosoftTeams build SBOM that Trivy read as installed zod and body-parser [(#13157)](https://github.com/prowler-cloud/prowler/pull/13157)
|
||||
|
||||
---
|
||||
|
||||
## [1.46.0] (Prowler v5.45.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1 [(#12865)](https://github.com/prowler-cloud/prowler/pull/12865)
|
||||
- Gunicorn workers are now recycled after 10000 requests (plus up to 1000 of jitter) to cap memory growth, configurable with `GUNICORN_MAX_REQUESTS` and `GUNICORN_MAX_REQUESTS_JITTER` [(#13047)](https://github.com/prowler-cloud/prowler/pull/13047)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 [(#12911)](https://github.com/prowler-cloud/prowler/pull/12911)
|
||||
- Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials [(#12912)](https://github.com/prowler-cloud/prowler/pull/12912)
|
||||
- Kubernetes kubeconfig validation accepts only inline credentials and known cluster fields, rejecting file-path fields (`tokenFile`, `client-certificate`, `client-key`, `certificate-authority`), command-based authentication (`exec`, `cmd-path`), `auth-provider` directives, proxy URLs and unknown user or cluster keys; stored kubeconfigs are re-validated before each scan and connection test [(#12952)](https://github.com/prowler-cloud/prowler/pull/12952)
|
||||
- `django` from 5.1.15 to 5.2.17, patching CVE-2026-15307 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
|
||||
- `dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
|
||||
- `urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
|
||||
- `pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 [(#12955)](https://github.com/prowler-cloud/prowler/pull/12955)
|
||||
- `GET /api/v1/users/{id}` no longer exposes another user's memberships, roles or tenant names from tenants other than the one in the request token [(#12965)](https://github.com/prowler-cloud/prowler/pull/12965)
|
||||
- Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the API container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy [(#13009)](https://github.com/prowler-cloud/prowler/pull/13009)
|
||||
- PowerShell from 7.5.11 to 7.5.12 in the API container image, bundling .NET runtime 9.0.21 [(#13037)](https://github.com/prowler-cloud/prowler/pull/13037)
|
||||
|
||||
---
|
||||
|
||||
## [1.45.0] (Prowler v5.44.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls [(#12871)](https://github.com/prowler-cloud/prowler/pull/12871)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. [(#12465)](https://github.com/prowler-cloud/prowler/pull/12465)
|
||||
- Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region [(#12746)](https://github.com/prowler-cloud/prowler/pull/12746)
|
||||
- Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded [(#12832)](https://github.com/prowler-cloud/prowler/pull/12832)
|
||||
- Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
|
||||
- Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
|
||||
- `POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit [(#12878)](https://github.com/prowler-cloud/prowler/pull/12878)
|
||||
- API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row [(#12882)](https://github.com/prowler-cloud/prowler/pull/12882)
|
||||
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j [(#12894)](https://github.com/prowler-cloud/prowler/pull/12894)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role [(#12893)](https://github.com/prowler-cloud/prowler/pull/12893)
|
||||
|
||||
---
|
||||
|
||||
## [1.44.0] (Prowler v5.43.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Report download URLs can be signed against a browser-reachable storage host via `DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL`, so downloads complete on deployments where storage is only reachable inside the container network [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552)
|
||||
- A scan report download no longer fails with a server error when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is unset, which is common on storage with no meaningful region [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552)
|
||||
- Lapsed pending invitations are reported as expired and no longer block a new invitation for the same email [(#12831)](https://github.com/prowler-cloud/prowler/pull/12831)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs [(#12804)](https://github.com/prowler-cloud/prowler/pull/12804)
|
||||
- PowerShell from 7.5.9 to 7.5.11 in the API container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901 [(#12811)](https://github.com/prowler-cloud/prowler/pull/12811)
|
||||
- Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374 [(#12848)](https://github.com/prowler-cloud/prowler/pull/12848)
|
||||
|
||||
---
|
||||
|
||||
## [1.43.0] (Prowler v5.42.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Speed up compliance overview ingestion by reading ThreatScore mappings from the compliance template instead of each finding, generating time-ordered `uuid7` row ids and grouping inserted rows by framework and requirement [(#12738)](https://github.com/prowler-cloud/prowler/pull/12738)
|
||||
|
||||
---
|
||||
|
||||
## [1.42.0] (Prowler v5.41.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>`, and `prowler-finding-<finding-uid>` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name [(#12540)](https://github.com/prowler-cloud/prowler/pull/12540)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues [(#12681)](https://github.com/prowler-cloud/prowler/pull/12681)
|
||||
|
||||
---
|
||||
|
||||
## [1.41.0] (Prowler v5.40.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS` is now applied in months instead of days, and negative values are rejected [(#12580)](https://github.com/prowler-cloud/prowler/pull/12580)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491 [(#12509)](https://github.com/prowler-cloud/prowler/pull/12509)
|
||||
- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549)
|
||||
|
||||
---
|
||||
|
||||
## [1.40.1] (Prowler v5.39.1)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
|
||||
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
|
||||
|
||||
---
|
||||
|
||||
## [1.40.0] (Prowler v5.39.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379)
|
||||
- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393)
|
||||
- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413)
|
||||
|
||||
---
|
||||
|
||||
## [1.39.0] (Prowler v5.38.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Attack Paths adds 20 AWS privilege-escalation detection queries from pathfinding.cloud, covering service PassRole escalations (Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM, Step Functions), CodeDeploy and Step Functions existing-resource abuse, role permissions-boundary removal with role assumption, and IAM Identity Center permission-set policy injection [(#12237)](https://github.com/prowler-cloud/prowler/pull/12237)
|
||||
- Attack Paths query metadata now carries an outcome (Code execution, Privilege escalation, Public exposure, or Resource inventory), exposed on the queries endpoint so the graph can show a terminal outcome node [(#12344)](https://github.com/prowler-cloud/prowler/pull/12344)
|
||||
- Container images now ship an SBOM and build provenance as OCI attestations [(#12352)](https://github.com/prowler-cloud/prowler/pull/12352)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Pin the container vulnerability scanner to Trivy v0.72.0, matching prowler-registry and partner-portal [(#12346)](https://github.com/prowler-cloud/prowler/pull/12346)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Compliance report output directory failures are now logged with the exception attached and fingerprinted by `errno` in Sentry, so `ENOSPC`, `ENOENT` and `EACCES` no longer share a single issue [(#12142)](https://github.com/prowler-cloud/prowler/pull/12142)
|
||||
- Restored the SDK dependency to `@master` now that the dependency bumps have landed there, and regenerated the lock. The API image no longer builds against a temporary integration branch [(#12309)](https://github.com/prowler-cloud/prowler/pull/12309)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- The API container image now verifies the checksum of every third-party binary it downloads (PowerShell, Trivy, zizmor) before installing it [(#12334)](https://github.com/prowler-cloud/prowler/pull/12334)
|
||||
- Upgrade aiohttp to 3.14.3 to pick up the fix for CVE-2026-69244 [(#12340)](https://github.com/prowler-cloud/prowler/pull/12340)
|
||||
- Upgrade cryptography to 50.0.0, closing CVE-2026-69247 and CVE-2026-69249 [(#12356)](https://github.com/prowler-cloud/prowler/pull/12356)
|
||||
|
||||
---
|
||||
|
||||
## [1.38.1] (Prowler v5.37.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Entra Conditional Access guest-user checks no longer report false FAILs in M365 scans: microsoft-kiota packages overridden to 1.9.10 so `guestOrExternalUserTypes` (a flags enum Graph serializes as a comma-separated string) deserializes correctly instead of returning an empty list [(#12315)](https://github.com/prowler-cloud/prowler/pull/12315)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- The API container image now builds on Debian 13 (trixie), taking its critical CVE count from 18 to 4 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped PowerShell, Trivy and uv in the API container image, clearing 14 high-severity CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped `workos` and `pyopenssl` so the API can move to `cryptography` 48.0.1 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Removed `gnupg` and `apt-transport-https` from the API container image [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- The API container image no longer ships `git`; removing it also dropped `perl`, `perl-modules`, `libperl` and `liberror-perl`, clearing 12 critical CVEs. Only `perl-base` remains, which Debian marks Essential and cannot be removed [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Removed `pip` from the API container image, clearing two high-severity CVEs in the vendored copies of `setuptools` and `msgpack` [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped `pillow` to 12.3.0, `httplib2` to 0.32.0 and `pyasn1` to 0.6.4 to resolve known CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
|
||||
---
|
||||
|
||||
## [1.38.0] (Prowler v5.37.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Attack Paths: four AWS privilege-escalation detection queries from pathfinding.cloud: cross-account role trust (STS-002), wildcard role trust (STS-003), user permissions-boundary removal (IAM-022), and IAM Identity Center permission-set escalation (SSO-001) [(#11460)](https://github.com/prowler-cloud/prowler/pull/11460)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths IAM privilege-escalation queries no longer build an all-nodes × all-resource-items cartesian product, fixing runtime errors and timeouts on accounts with many IAM roles, users, or groups [(#12136)](https://github.com/prowler-cloud/prowler/pull/12136)
|
||||
- `task_args` serialization no longer returns HTTP 500 errors when Celery truncates stored task keyword arguments [(#12165)](https://github.com/prowler-cloud/prowler/pull/12165)
|
||||
- Attack Paths predefined queries on migrated graphs are now scoped with the provider label, letting the graph database seed from its label index instead of a global label scan and preventing query timeouts on Neptune [(#12167)](https://github.com/prowler-cloud/prowler/pull/12167)
|
||||
- Authentication with an API key whose owning user was deleted now returns `401` instead of an unhandled `AttributeError`, and user deletion now revokes the user's API keys across all their tenants [(#12210)](https://github.com/prowler-cloud/prowler/pull/12210)
|
||||
- AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current [(#12212)](https://github.com/prowler-cloud/prowler/pull/12212)
|
||||
- SAML users without a `userType` attribute and without an existing role in the SAML tenant now receive a least-privilege `read_only` fallback role; a numeric suffix is used when that name belongs to a role with different permissions [(#12223)](https://github.com/prowler-cloud/prowler/pull/12223)
|
||||
- Social signups create users and authentication records in one database transaction, preventing incomplete accounts when provisioning fails [(#12245)](https://github.com/prowler-cloud/prowler/pull/12245)
|
||||
- Requesting integrations with a sparse fieldset that leaves out `configuration` no longer returns HTTP 500 errors when the tenant has a Jira integration [(#12261)](https://github.com/prowler-cloud/prowler/pull/12261)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Provider deletion and connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility [(#12216)](https://github.com/prowler-cloud/prowler/pull/12216)
|
||||
|
||||
---
|
||||
|
||||
## [1.37.0] (Prowler v5.36.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
|
||||
- Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database [(#11875)](https://github.com/prowler-cloud/prowler/pull/11875)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped [(#12002)](https://github.com/prowler-cloud/prowler/pull/12002)
|
||||
- Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Output generation now removes the scan's temporary output directory before writing, so a re-run of the task for the same scan (e.g. broker redelivery after a worker is killed mid-run) no longer appends to the previous run's files and duplicates finding rows in the exported CSV and other outputs [(#12097)](https://github.com/prowler-cloud/prowler/pull/12097)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers` [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Kubernetes kubeconfig validation now rejects legacy `auth-provider.config.cmd-path` command authentication in Prowler Cloud/API [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
|
||||
|
||||
---
|
||||
|
||||
## [1.36.0] (Prowler v5.35.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults [(#12009)](https://github.com/prowler-cloud/prowler/pull/12009)
|
||||
- Attack Paths scans handle provider deletion races cleanly, detect stale tasks after 16 hours, use backend-specific graph synchronization batches, and report exhausted Neptune write retries with the original database error [(#12019)](https://github.com/prowler-cloud/prowler/pull/12019)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
|
||||
- Social account linking requires a verified matching email from both the identity provider and the existing user account without sending account connection notifications [(#12013)](https://github.com/prowler-cloud/prowler/pull/12013)
|
||||
|
||||
---
|
||||
|
||||
## [1.35.0] (Prowler v5.34.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `rls_transaction` now falls back directly to the primary DB for connection-level mid-query read replica failures via `execute_wrapper`, reducing non-streaming read crashes during replica recovery [(#10379)](https://github.com/prowler-cloud/prowler/pull/10379)
|
||||
- RBAC permission gates now combine permissions from every role assigned to a user in the active tenant [(#11979)](https://github.com/prowler-cloud/prowler/pull/11979)
|
||||
- `attack-paths-cleanup-stale-scans` now retries worker pings and checks recent scan activity before failing scans and removing temporary databases [(#11986)](https://github.com/prowler-cloud/prowler/pull/11986)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- User role relationship updates are limited to the active tenant to preserve role assignments in other tenants [(#11903)](https://github.com/prowler-cloud/prowler/pull/11903)
|
||||
- `api` container image removes the unused Debian `libxml2` runtime package and scopes the `CVE-2026-13221` Trivy exception to unaffected Perl 5.36 packages [(#11991)](https://github.com/prowler-cloud/prowler/pull/11991)
|
||||
|
||||
---
|
||||
|
||||
## [1.34.2] (Prowler v5.33.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
+57
-7
@@ -1,23 +1,43 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG POWERSHELL_VERSION=7.5.12
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
|
||||
ARG TRIVY_VERSION=0.71.2
|
||||
ARG TRIVY_VERSION=0.76.1
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
|
||||
ARG TRIVY_SHA256_AMD64=c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0
|
||||
ARG TRIVY_SHA256_ARM64=e174dff2a6dba5e3ae41f8d0537a6324405bd08132497340304c6b20e0f16a1f
|
||||
ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd
|
||||
ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
|
||||
|
||||
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
|
||||
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
|
||||
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
|
||||
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
|
||||
# gzip 1.13-1+deb13u1 CVE-2026-41992
|
||||
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
|
||||
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
|
||||
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
|
||||
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
|
||||
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
|
||||
# openssl packages are flagged separately, so all are named.
|
||||
# Drop each one once the base image ships its fixed version.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
git \
|
||||
libicu72 \
|
||||
libicu76 \
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
@@ -28,7 +48,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
git \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade \
|
||||
util-linux libssl3t64 openssl openssl-provider-legacy \
|
||||
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
@@ -40,6 +62,9 @@ RUN ARCH=$(uname -m) && \
|
||||
else \
|
||||
echo "Unsupported architecture: $ARCH" && exit 1 ; \
|
||||
fi && \
|
||||
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
|
||||
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
|
||||
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
|
||||
mkdir -p /opt/microsoft/powershell/7 && \
|
||||
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
|
||||
chmod +x /opt/microsoft/powershell/7/pwsh && \
|
||||
@@ -56,6 +81,9 @@ RUN ARCH=$(uname -m) && \
|
||||
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
|
||||
fi && \
|
||||
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
|
||||
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
|
||||
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
|
||||
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
|
||||
tar zxf /tmp/trivy.tar.gz -C /tmp && \
|
||||
mv /tmp/trivy /usr/local/bin/trivy && \
|
||||
chmod +x /usr/local/bin/trivy && \
|
||||
@@ -74,6 +102,9 @@ RUN ARCH=$(uname -m) && \
|
||||
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
|
||||
fi && \
|
||||
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
|
||||
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
|
||||
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
|
||||
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
|
||||
mkdir -p /tmp/zizmor-extract && \
|
||||
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
|
||||
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
|
||||
@@ -94,7 +125,7 @@ RUN mkdir -p /tmp/prowler_api_output
|
||||
COPY --chown=prowler:prowler pyproject.toml uv.lock ./
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir uv==0.11.14
|
||||
pip install --no-cache-dir uv==0.13.4
|
||||
|
||||
ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
|
||||
@@ -102,24 +133,43 @@ ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
RUN uv sync --locked --no-install-project && \
|
||||
rm -rf ~/.cache/uv
|
||||
|
||||
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
|
||||
# Invoked as a module so the base image's Python minor version is not baked
|
||||
# into a site-packages path. Drop the MicrosoftTeams build SBOM: Trivy reports the npm
|
||||
# tooling it declares as installed, though none ships (see .trivyignore.yaml).
|
||||
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell && \
|
||||
rm -rf /home/prowler/.local/share/powershell/Modules/MicrosoftTeams/*/_manifest
|
||||
|
||||
USER root
|
||||
|
||||
# Remove build-only packages from the final image after Python dependencies are installed.
|
||||
# git is only needed by uv sync for the `prowler @ git+...` dependency; purging it drops perl too.
|
||||
# wget stays: the compose healthcheck shells out to it.
|
||||
RUN apt-get purge -y --auto-remove \
|
||||
gcc \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
libxmlsec1t64 \
|
||||
libxmlsec1t64-openssl \
|
||||
pkg-config \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# pip is build-only; the entrypoint runs uv against the prepared venv. uv stays.
|
||||
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
|
||||
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
|
||||
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
|
||||
|
||||
USER prowler
|
||||
|
||||
COPY --chown=prowler:prowler src/backend/ ./backend/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
`attack-paths-cleanup-stale-scans` now retries worker pings and checks recent scan activity before failing scans and removing temporary databases
|
||||
@@ -1 +0,0 @@
|
||||
RBAC permission gates now combine permissions from every role assigned to a user in the active tenant
|
||||
@@ -1 +0,0 @@
|
||||
`rls_transaction` now falls back directly to the primary DB for connection-level mid-query read replica failures via `execute_wrapper`, reducing non-streaming read crashes during replica recovery
|
||||
@@ -1 +0,0 @@
|
||||
`api` container image removes the unused Debian `libxml2` runtime package and scopes the `CVE-2026-13221` Trivy exception to unaffected Perl 5.36 packages
|
||||
@@ -1 +0,0 @@
|
||||
User role relationship updates are limited to the active tenant to preserve role assignments in other tenants
|
||||
+68
-49
@@ -25,8 +25,8 @@ authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
|
||||
dependencies = [
|
||||
"celery (==5.6.2)",
|
||||
"dj-rest-auth[with_social,jwt] (==7.0.1)",
|
||||
"django (==5.1.15)",
|
||||
"django-allauth[saml] (==65.15.0)",
|
||||
"django (==5.2.17)",
|
||||
"django-allauth[saml] (==65.19.7)",
|
||||
"django-celery-beat (==2.9.0)",
|
||||
"django-celery-results (==2.6.0)",
|
||||
"django-cors-headers==4.4.0",
|
||||
@@ -34,7 +34,7 @@ dependencies = [
|
||||
"django-filter==24.3",
|
||||
"django-guid==3.5.0",
|
||||
"django-postgres-extra (==2.0.9)",
|
||||
"djangorestframework==3.15.2",
|
||||
"djangorestframework==3.18.3",
|
||||
"djangorestframework-jsonapi==7.0.2",
|
||||
"djangorestframework-simplejwt (==5.5.1)",
|
||||
"drf-nested-routers (==0.95.0)",
|
||||
@@ -61,9 +61,9 @@ dependencies = [
|
||||
"cartography (==0.138.1)",
|
||||
"gevent (==25.9.1)",
|
||||
"werkzeug (==3.1.7)",
|
||||
"sqlparse (==0.5.5)",
|
||||
"sqlparse (==0.6.0)",
|
||||
"fonttools (==4.62.1)",
|
||||
"uvicorn-worker (==0.4.0)",
|
||||
"uvicorn-worker (==0.4.0)"
|
||||
]
|
||||
description = "Prowler's API (Django/DRF)"
|
||||
license = "Apache-2.0"
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.35.0"
|
||||
version = "1.49.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -92,6 +92,7 @@ extend-select = [
|
||||
|
||||
[tool.uv]
|
||||
# Transitive pins matching master to avoid silent drift; bump deliberately.
|
||||
# workos is api-only; pyopenssl matches master (PROWLER-2310).
|
||||
constraint-dependencies = [
|
||||
"about-time==4.2.1",
|
||||
"adal==1.2.7",
|
||||
@@ -99,7 +100,7 @@ constraint-dependencies = [
|
||||
"aiobotocore==2.25.1",
|
||||
"aiofiles==24.1.0",
|
||||
"aiohappyeyeballs==2.6.1",
|
||||
"aiohttp==3.14.0",
|
||||
"aiohttp==3.14.5",
|
||||
"aioitertools==0.13.0",
|
||||
"aiosignal==1.4.0",
|
||||
"alibabacloud-actiontrail20200706==2.4.1",
|
||||
@@ -128,7 +129,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sls20201230==5.9.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.4",
|
||||
"alibabacloud-tea-openapi==0.4.6",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -136,7 +137,7 @@ constraint-dependencies = [
|
||||
"aliyun-log-fastpb==0.2.0",
|
||||
"amqp==5.3.1",
|
||||
"annotated-types==0.7.0",
|
||||
"anyio==4.12.1",
|
||||
"anyio==4.14.2",
|
||||
"applicationinsights==0.11.10",
|
||||
"apscheduler==3.11.2",
|
||||
"argcomplete==3.5.3",
|
||||
@@ -195,7 +196,7 @@ constraint-dependencies = [
|
||||
"botocore==1.40.61",
|
||||
"cartography==0.138.1",
|
||||
"celery==5.6.2",
|
||||
"certifi==2026.1.4",
|
||||
"certifi==2026.10.19",
|
||||
"cffi==2.0.0",
|
||||
"charset-normalizer==3.4.4",
|
||||
"circuitbreaker==2.1.3",
|
||||
@@ -210,9 +211,9 @@ constraint-dependencies = [
|
||||
"coverage==7.5.4",
|
||||
"cron-descriptor==1.4.5",
|
||||
"crowdstrike-falconpy==1.6.0",
|
||||
"cryptography==46.0.7",
|
||||
"cryptography==50.0.0",
|
||||
"cycler==0.12.1",
|
||||
"darabonba-core==1.0.5",
|
||||
"darabonba-core==1.0.8",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
"debugpy==1.8.20",
|
||||
@@ -221,8 +222,8 @@ constraint-dependencies = [
|
||||
"dill==0.4.1",
|
||||
"distro==1.9.0",
|
||||
"dj-rest-auth==7.0.1",
|
||||
"django==5.1.15",
|
||||
"django-allauth==65.15.0",
|
||||
"django==5.2.17",
|
||||
"django-allauth==65.19.7",
|
||||
"django-celery-beat==2.9.0",
|
||||
"django-celery-results==2.6.0",
|
||||
"django-cors-headers==4.4.0",
|
||||
@@ -233,10 +234,10 @@ constraint-dependencies = [
|
||||
"django-postgres-extra==2.0.9",
|
||||
"django-silk==5.3.2",
|
||||
"django-timezone-field==7.2.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"djangorestframework==3.18.3",
|
||||
"djangorestframework-jsonapi==7.0.2",
|
||||
"djangorestframework-simplejwt==5.5.1",
|
||||
"dnspython==2.8.0",
|
||||
"dnspython==2.9.0",
|
||||
"docker==7.1.0",
|
||||
"dogpile-cache==1.5.0",
|
||||
"dparse==0.6.4",
|
||||
@@ -245,7 +246,7 @@ constraint-dependencies = [
|
||||
"drf-simple-apikey==2.2.1",
|
||||
"drf-spectacular==0.27.2",
|
||||
"drf-spectacular-jsonapi==0.5.1",
|
||||
"dulwich==1.2.5",
|
||||
"dulwich==1.2.9",
|
||||
"duo-client==5.5.0",
|
||||
"durationpy==0.10",
|
||||
"email-validator==2.2.0",
|
||||
@@ -277,7 +278,7 @@ constraint-dependencies = [
|
||||
"h2==4.3.0",
|
||||
"hpack==4.1.0",
|
||||
"httpcore==1.0.9",
|
||||
"httplib2==0.31.2",
|
||||
"httplib2==0.32.0",
|
||||
"httpx==0.28.1",
|
||||
"humanfriendly==10.0",
|
||||
"hyperframe==6.1.0",
|
||||
@@ -314,13 +315,13 @@ constraint-dependencies = [
|
||||
"matplotlib==3.10.8",
|
||||
"mccabe==0.7.0",
|
||||
"mdurl==0.1.2",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"microsoft-kiota-authentication-azure==1.9.9",
|
||||
"microsoft-kiota-http==1.9.9",
|
||||
"microsoft-kiota-serialization-form==1.9.9",
|
||||
"microsoft-kiota-serialization-json==1.9.9",
|
||||
"microsoft-kiota-serialization-multipart==1.9.9",
|
||||
"microsoft-kiota-serialization-text==1.9.9",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"microsoft-security-utilities-secret-masker==1.0.0b4",
|
||||
"msal==1.35.0b1",
|
||||
"msal-extensions==1.2.0",
|
||||
@@ -336,8 +337,8 @@ constraint-dependencies = [
|
||||
"nest-asyncio==1.6.0",
|
||||
"nltk==3.9.4",
|
||||
"numpy==2.2.6",
|
||||
"oauthlib==3.3.1",
|
||||
"oci==2.169.0",
|
||||
"oauthlib==4.0.0",
|
||||
"oci==2.184.1",
|
||||
"openai==1.109.1",
|
||||
"openstacksdk==4.2.0",
|
||||
"opentelemetry-api==1.39.1",
|
||||
@@ -349,7 +350,7 @@ constraint-dependencies = [
|
||||
"pagerduty==6.1.0",
|
||||
"pandas==2.2.3",
|
||||
"pbr==7.0.3",
|
||||
"pillow==12.2.0",
|
||||
"pillow==13.0.0",
|
||||
"pkginfo==1.12.1.2",
|
||||
"platformdirs==4.5.1",
|
||||
"plotly==6.5.2",
|
||||
@@ -365,8 +366,8 @@ constraint-dependencies = [
|
||||
"psycopg2-binary==2.9.9",
|
||||
"py-deviceid==0.1.1",
|
||||
"py-iam-expand==0.3.0",
|
||||
"py-ocsf-models==0.8.1",
|
||||
"pyasn1==0.6.3",
|
||||
"py-ocsf-models==0.10.0",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.14.0",
|
||||
"pycparser==3.0",
|
||||
@@ -374,11 +375,11 @@ constraint-dependencies = [
|
||||
"pydantic-core==2.41.5",
|
||||
"pygithub==2.8.0",
|
||||
"pygments==2.20.0",
|
||||
"pyjwt==2.13.0",
|
||||
"pyjwt==2.14.0",
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.0.0",
|
||||
"pyopenssl==26.4.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pyreadline3==3.5.4",
|
||||
"pysocks==1.7.1",
|
||||
@@ -422,7 +423,7 @@ constraint-dependencies = [
|
||||
"six==1.17.0",
|
||||
"slack-sdk==3.39.0",
|
||||
"sniffio==1.3.1",
|
||||
"sqlparse==0.5.5",
|
||||
"sqlparse==0.6.0",
|
||||
"statsd==4.0.1",
|
||||
"std-uritemplate==2.0.8",
|
||||
"stevedore==5.6.0",
|
||||
@@ -438,7 +439,7 @@ constraint-dependencies = [
|
||||
"tzdata==2025.3",
|
||||
"tzlocal==5.3.1",
|
||||
"uritemplate==4.2.0",
|
||||
"urllib3==2.7.0",
|
||||
"urllib3==2.8.0",
|
||||
"uuid6==2024.7.10",
|
||||
"uvicorn==0.49.0",
|
||||
"uvloop==0.22.1",
|
||||
@@ -447,16 +448,16 @@ constraint-dependencies = [
|
||||
"wcwidth==0.5.3",
|
||||
"websocket-client==1.9.0",
|
||||
"werkzeug==3.1.7",
|
||||
"workos==6.0.8",
|
||||
"workos==8.3.0",
|
||||
"wrapt==1.17.3",
|
||||
"xlsxwriter==3.2.9",
|
||||
"xmlsec==1.3.17",
|
||||
"xmltodict==1.0.2",
|
||||
"yarl==1.22.0",
|
||||
"yarl==1.25.1",
|
||||
"zipp==3.23.0",
|
||||
"zope-event==6.1",
|
||||
"zope-interface==8.2",
|
||||
"zstd==1.5.7.3"
|
||||
"zstd==1.5.7.2"
|
||||
]
|
||||
# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
|
||||
# Attack Paths does not ingest Okta today, so override the Cartography
|
||||
@@ -466,22 +467,40 @@ constraint-dependencies = [
|
||||
# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not
|
||||
# ingest Azure today, so override the Cartography dependency to the Prowler pin.
|
||||
#
|
||||
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
|
||||
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
|
||||
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
|
||||
# SDK's hard pin; override it to the patched, kiota-aligned version.
|
||||
# prowler@master hard-pins the microsoft-kiota packages in [project.dependencies].
|
||||
# microsoft-kiota-serialization-json 1.9.10 fixes get_collection_of_enum_values
|
||||
# returning [] for flags enums serialized as CSV strings (microsoft/kiota-python#515),
|
||||
# which broke the Entra Conditional Access guest-user checks; the kiota packages
|
||||
# release in lockstep and 1.9.10 requires microsoft-kiota-abstractions>=1.9.10, which
|
||||
# a constraint cannot satisfy against the SDK's hard pins, so override the whole set
|
||||
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
|
||||
#
|
||||
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
|
||||
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
|
||||
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
|
||||
# against the SDK's hard pins, so override them to the patched versions until the SDK
|
||||
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
|
||||
# The API installs prowler from a pinned master git rev whose dulwich and pyjwt pins trail
|
||||
# the root pyproject.toml. dulwich 1.2.9 patches GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf,
|
||||
# GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx, and pyjwt 2.14.0 patches GHSA-ffc3-869f-jxw9,
|
||||
# so override both until that rev is refreshed. pyjwt keeps the [crypto] extra because an
|
||||
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
|
||||
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
# huaweicloudsdkcore (via prowler) pins pymongo==4.15.1 exactly; CVE-2026-96748 and
|
||||
# CVE-2026-96749 are fixed in 4.18.2. Only bson types are used, so the override is safe.
|
||||
"pymongo==4.18.3",
|
||||
# prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below
|
||||
# 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1,
|
||||
# 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0
|
||||
# (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is
|
||||
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
|
||||
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
|
||||
"cryptography==50.0.0",
|
||||
"azure-mgmt-containerservice==34.1.0",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"dulwich==1.2.5",
|
||||
"pyjwt[crypto]==2.13.0"
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"dulwich==1.2.9",
|
||||
"pyjwt[crypto]==2.14.0"
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.db_router import MainRouter, write_db_alias
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
Membership,
|
||||
@@ -10,10 +12,37 @@ from api.models import (
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.utils import accept_invitation_for_user
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
|
||||
|
||||
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
@staticmethod
|
||||
def _get_social_account_name(extra_data: dict, email: str) -> str:
|
||||
name_field = User._meta.get_field("name")
|
||||
for value in (
|
||||
extra_data.get("name"),
|
||||
extra_data.get("login"),
|
||||
extra_data.get("username"),
|
||||
email,
|
||||
):
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
|
||||
candidate = value.strip()[: name_field.max_length].rstrip()
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
try:
|
||||
name_field.run_validators(candidate)
|
||||
except ValidationError:
|
||||
continue
|
||||
|
||||
return candidate
|
||||
|
||||
raise ValueError("Social account does not provide a valid user identity.")
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_email(email: str):
|
||||
try:
|
||||
@@ -38,8 +67,13 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
return None
|
||||
|
||||
def pre_social_login(self, request, sociallogin):
|
||||
# Link existing accounts with the same email address
|
||||
email = sociallogin.account.extra_data.get("email")
|
||||
# The provider account is already bound, so no email-based linking is needed.
|
||||
if sociallogin.account.pk:
|
||||
return
|
||||
|
||||
# Prefer the normalized email populated by allauth. GitHub can return the
|
||||
# primary email separately from the profile stored in extra_data.
|
||||
email = sociallogin.user.email or sociallogin.account.extra_data.get("email")
|
||||
if sociallogin.provider.id == "saml":
|
||||
# For SAML, the asserted NameID email cannot be trusted on its own:
|
||||
# any tenant can claim any email domain in its SAML configuration. To
|
||||
@@ -80,6 +114,17 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
if email:
|
||||
existing_user = self.get_user_by_email(email)
|
||||
if existing_user:
|
||||
email_is_verified = EmailAddress.objects.filter(
|
||||
user=existing_user,
|
||||
email__iexact=email,
|
||||
verified=True,
|
||||
).exists()
|
||||
provider_verified_email = any(
|
||||
address.verified and address.email.casefold() == email.casefold()
|
||||
for address in sociallogin.email_addresses
|
||||
)
|
||||
if not email_is_verified or not provider_verified_email:
|
||||
raise ImmediateHttpResponse(HttpResponseForbidden())
|
||||
sociallogin.connect(request, existing_user)
|
||||
|
||||
def save_user(self, request, sociallogin, form=None):
|
||||
@@ -88,17 +133,17 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
and is about to be saved to the DB for the first time.
|
||||
"""
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
# Allauth saves the user without an explicit alias. Route that save
|
||||
# through admin so every signup record shares this transaction.
|
||||
with write_db_alias(MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
provider = sociallogin.provider.id
|
||||
extra = sociallogin.account.extra_data
|
||||
|
||||
if provider != "saml":
|
||||
# Handle other providers (e.g., GitHub, Google)
|
||||
user.name = self._get_social_account_name(extra, user.email)
|
||||
user.save(using=MainRouter.admin_db)
|
||||
social_account_name = extra.get("name")
|
||||
if social_account_name:
|
||||
user.name = social_account_name
|
||||
user.save(using=MainRouter.admin_db)
|
||||
|
||||
invitation_token = self._get_invitation_token(request)
|
||||
if invitation_token:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from api.attack_paths.queries import (
|
||||
AttackPathsQueryDefinition,
|
||||
AttackPathsQueryOutcome,
|
||||
AttackPathsQueryParameterDefinition,
|
||||
get_queries_for_provider,
|
||||
get_query_by_id,
|
||||
@@ -7,6 +8,7 @@ from api.attack_paths.queries import (
|
||||
|
||||
__all__ = [
|
||||
"AttackPathsQueryDefinition",
|
||||
"AttackPathsQueryOutcome",
|
||||
"AttackPathsQueryParameterDefinition",
|
||||
"get_queries_for_provider",
|
||||
"get_query_by_id",
|
||||
|
||||
@@ -12,7 +12,7 @@ Two responsibilities:
|
||||
|
||||
Label-injection pipeline:
|
||||
|
||||
1. **Protect** string literals and line comments (placeholder replacement).
|
||||
1. **Protect** string literals and comments (placeholder replacement).
|
||||
2. **Split** by top-level clause keywords to track clause context.
|
||||
3. **Pass A** - inject into *labeled* node patterns in ALL segments.
|
||||
4. **Pass B** - inject into *bare* node patterns in MATCH segments only.
|
||||
@@ -25,10 +25,12 @@ from rest_framework.exceptions import ValidationError
|
||||
from tasks.jobs.attack_paths.config import get_provider_label
|
||||
|
||||
# Step 1 - String / comment protection
|
||||
# Single combined regex: strings first, then line comments
|
||||
# Single combined regex: strings first, then line and block comments
|
||||
# The regex engine finds the leftmost match, so a string like 'https://prowler.com'
|
||||
# is consumed as a string before the // inside it can match as a comment
|
||||
_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
|
||||
_PROTECTED_RE = re.compile(
|
||||
r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*|/\*[\s\S]*?\*/"
|
||||
)
|
||||
|
||||
# Step 2 - Clause splitting
|
||||
# `OPTIONAL MATCH` must come before `MATCH` to avoid partial matching
|
||||
@@ -143,18 +145,26 @@ def inject_label(cypher: str, label: str) -> str:
|
||||
|
||||
# Patterns that indicate SSRF or dangerous procedure calls
|
||||
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
|
||||
# Neo4j accepts whitespace (and therefore comments) around the dots of a
|
||||
# qualified procedure name, so `apoc . load` is `apoc.load`.
|
||||
_BLOCKED_APOC_NAMESPACES = (
|
||||
"load",
|
||||
"import",
|
||||
"export",
|
||||
"cypher",
|
||||
"systemdb",
|
||||
"config",
|
||||
"periodic",
|
||||
"do",
|
||||
"trigger",
|
||||
"custom",
|
||||
)
|
||||
_BLOCKED_PATTERNS = [
|
||||
re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.load\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.import\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.export\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.cypher\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.config\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.periodic\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.do\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.trigger\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.custom\b", re.IGNORECASE),
|
||||
*(
|
||||
re.compile(rf"\bapoc\s*\.\s*{namespace}\b", re.IGNORECASE)
|
||||
for namespace in _BLOCKED_APOC_NAMESPACES
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -162,10 +172,11 @@ def validate_custom_query(cypher: str) -> None:
|
||||
"""Reject queries containing known SSRF or dangerous procedure patterns.
|
||||
|
||||
Raises ValidationError if a blocked pattern is found.
|
||||
String literals and comments are stripped before matching to avoid
|
||||
false positives.
|
||||
String literals and comments are replaced by a space before matching:
|
||||
literals cannot trigger false positives and a comment cannot split a
|
||||
keyword pair such as `LOAD /**/ CSV`, which Neo4j still parses as `LOAD CSV`.
|
||||
"""
|
||||
stripped = _PROTECTED_RE.sub("", cypher)
|
||||
stripped = _PROTECTED_RE.sub(" ", cypher)
|
||||
for pattern in _BLOCKED_PATTERNS:
|
||||
if pattern.search(stripped):
|
||||
raise ValidationError({"query": "Query contains a blocked operation"})
|
||||
@@ -27,6 +27,7 @@ from django.conf import (
|
||||
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
|
||||
|
||||
TEMP_DB_PREFIX = "db-tmp-scan-"
|
||||
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
|
||||
|
||||
# Exceptions
|
||||
@@ -44,6 +45,10 @@ class GraphDatabaseQueryException(Exception):
|
||||
return self.message
|
||||
|
||||
|
||||
class NeptuneWriteRetryExhaustedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
@@ -202,6 +207,11 @@ def drop_database(database: str) -> None:
|
||||
sink_module.get_backend().drop_database(database)
|
||||
|
||||
|
||||
def list_databases() -> list[str]:
|
||||
"""List database names on the ingest cluster. Temp scan DBs always live here."""
|
||||
return ingest.list_databases()
|
||||
|
||||
|
||||
def drop_subgraph(database: str, provider_id: str) -> int:
|
||||
return sink_module.get_backend().drop_subgraph(database, provider_id)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from api.attack_paths.ingest.driver import (
|
||||
get_session,
|
||||
get_uri,
|
||||
init_driver,
|
||||
list_databases,
|
||||
run_cypher,
|
||||
)
|
||||
|
||||
@@ -25,5 +26,6 @@ __all__ = [
|
||||
"get_session",
|
||||
"get_uri",
|
||||
"init_driver",
|
||||
"list_databases",
|
||||
"run_cypher",
|
||||
]
|
||||
@@ -165,6 +165,14 @@ def drop_database(database: str) -> None:
|
||||
session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA")
|
||||
|
||||
|
||||
def list_databases() -> list[str]:
|
||||
"""List every database name on the Neo4j temp-database cluster."""
|
||||
# A cluster returns one row per hosting server, so dedupe on name
|
||||
with get_session() as session:
|
||||
result = session.run("SHOW DATABASES YIELD name RETURN DISTINCT name")
|
||||
return [record["name"] for record in result]
|
||||
|
||||
|
||||
def clear_cache(database: str) -> None:
|
||||
"""Best-effort cache clear for a Neo4j database."""
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
|
||||
@@ -4,11 +4,13 @@ from api.attack_paths.queries.registry import (
|
||||
)
|
||||
from api.attack_paths.queries.types import (
|
||||
AttackPathsQueryDefinition,
|
||||
AttackPathsQueryOutcome,
|
||||
AttackPathsQueryParameterDefinition,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AttackPathsQueryDefinition",
|
||||
"AttackPathsQueryOutcome",
|
||||
"AttackPathsQueryParameterDefinition",
|
||||
"get_queries_for_provider",
|
||||
"get_query_by_id",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,38 @@
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttackPathsQueryOutcomeMeta:
|
||||
"""Display metadata for an outcome kind.
|
||||
|
||||
`label` and `partial` are properties of the outcome *kind*, not of an
|
||||
individual query, so they live here once and every query just references a
|
||||
kind. `partial` marks a latent/posture outcome (e.g. inventory) that the UI
|
||||
renders as a marker rather than a full realized outcome.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
label: str
|
||||
partial: bool = False
|
||||
|
||||
|
||||
class AttackPathsQueryOutcome(Enum):
|
||||
"""The terminal impact an attack-path query leads to.
|
||||
|
||||
Set per query and exposed by the API so the UI can render the graph's
|
||||
terminal outcome node. The taxonomy is shared with Prowler Hub's attack-path
|
||||
diagram (whose terminal labels match these values).
|
||||
"""
|
||||
|
||||
CODE_EXECUTION = AttackPathsQueryOutcomeMeta("code_execution", "Code execution")
|
||||
PRIVILEGE_ESCALATION = AttackPathsQueryOutcomeMeta(
|
||||
"privilege_escalation", "Privilege escalation"
|
||||
)
|
||||
PUBLIC_EXPOSURE = AttackPathsQueryOutcomeMeta("public_exposure", "Public exposure")
|
||||
RESOURCE_INVENTORY = AttackPathsQueryOutcomeMeta(
|
||||
"resource_inventory", "Resource inventory", partial=True
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -36,4 +70,5 @@ class AttackPathsQueryDefinition:
|
||||
provider: str
|
||||
cypher: str
|
||||
attribution: AttackPathsQueryAttribution | None = None
|
||||
outcome: AttackPathsQueryOutcome | None = None
|
||||
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
|
||||
@@ -10,6 +10,28 @@ import neo4j.exceptions
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RetryExhaustedError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
retry_context: str,
|
||||
method_name: str,
|
||||
attempts: int,
|
||||
elapsed_seconds: float,
|
||||
last_error: Exception,
|
||||
) -> None:
|
||||
self.retry_context = retry_context
|
||||
self.method_name = method_name
|
||||
self.attempts = attempts
|
||||
self.elapsed_seconds = elapsed_seconds
|
||||
self.last_error = last_error
|
||||
last_message = getattr(last_error, "message", None) or str(last_error)
|
||||
super().__init__(
|
||||
f"{retry_context} {method_name} failed after {attempts} attempts over "
|
||||
f"{elapsed_seconds:.3f}s. Last error: {last_message}"
|
||||
)
|
||||
|
||||
|
||||
class RetryableSession:
|
||||
"""Wrapper around ``neo4j.Session`` with a refreshable retry policy."""
|
||||
|
||||
@@ -19,11 +41,13 @@ class RetryableSession:
|
||||
max_retries: int,
|
||||
retry_if: Callable[[Exception], bool] | None = None,
|
||||
initial_retry_delay_seconds: float = 0,
|
||||
retry_context: str | None = None,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._max_retries = max(0, max_retries)
|
||||
self._retry_if = retry_if
|
||||
self._initial_retry_delay_seconds = max(0.0, initial_retry_delay_seconds)
|
||||
self._retry_context = retry_context
|
||||
self._session = self._session_factory()
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -54,6 +78,7 @@ class RetryableSession:
|
||||
def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
attempt = 0
|
||||
last_exc: Exception | None = None
|
||||
started_at = time.monotonic()
|
||||
|
||||
while attempt <= self._max_retries:
|
||||
try:
|
||||
@@ -68,17 +93,38 @@ class RetryableSession:
|
||||
attempt += 1
|
||||
|
||||
if attempt > self._max_retries:
|
||||
if self._retry_context is not None:
|
||||
raise RetryExhaustedError(
|
||||
retry_context=self._retry_context,
|
||||
method_name=method_name,
|
||||
attempts=attempt,
|
||||
elapsed_seconds=time.monotonic() - started_at,
|
||||
last_error=exc,
|
||||
) from exc
|
||||
raise
|
||||
|
||||
delay = self._retry_delay(attempt)
|
||||
logger.warning(
|
||||
"Graph session %s failed with %s; retry %s/%s in %.3fs",
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
if self._retry_context is not None:
|
||||
error_message = getattr(exc, "message", None) or str(exc)
|
||||
logger.warning(
|
||||
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
|
||||
self._retry_context,
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
error_message,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Graph session %s failed with %s; retry %s/%s in %.3fs",
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
self._refresh_session()
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
|
||||
@@ -15,6 +15,8 @@ class SinkDatabase(Protocol):
|
||||
has a single graph, and isolation is label-based).
|
||||
"""
|
||||
|
||||
sync_batch_size: int
|
||||
|
||||
def init(self) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
@@ -43,6 +45,10 @@ class SinkDatabase(Protocol):
|
||||
parameters: dict[str, Any] | None = None,
|
||||
) -> neo4j.graph.Graph: ...
|
||||
|
||||
def is_persisted_element_id(self, element_id: str) -> bool:
|
||||
"""Whether an element id belongs to a stored entity rather than one minted at query time."""
|
||||
return True
|
||||
|
||||
def create_database(self, database: str) -> None: ...
|
||||
|
||||
def drop_database(self, database: str) -> None: ...
|
||||
|
||||
@@ -7,6 +7,7 @@ path while phase-1 drains tenant DBs.
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
@@ -54,6 +55,8 @@ DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
class Neo4jSink(SinkDatabase):
|
||||
"""Neo4j-backed sink. Multi-database cluster; tenant isolation is physical."""
|
||||
|
||||
sync_batch_size = env.int("ATTACK_PATHS_NEO4J_SYNC_BATCH_SIZE", default=1000)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._driver: neo4j.Driver | None = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -184,6 +187,13 @@ class Neo4jSink(SinkDatabase):
|
||||
|
||||
return session.execute_read(_run)
|
||||
|
||||
# APOC virtual nodes report a bare negative number and virtual
|
||||
# relationships a random UUID, neither in the stored-entity format.
|
||||
_PERSISTED_ELEMENT_ID_RE = re.compile(r"^\d+:[^:]+:\d+$")
|
||||
|
||||
def is_persisted_element_id(self, element_id: str) -> bool:
|
||||
return bool(self._PERSISTED_ELEMENT_ID_RE.match(element_id))
|
||||
|
||||
def create_database(self, database: str) -> None:
|
||||
with self.get_session() as session:
|
||||
session.run(
|
||||
@@ -203,7 +213,7 @@ class Neo4jSink(SinkDatabase):
|
||||
"""
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
@@ -251,7 +261,7 @@ class Neo4jSink(SinkDatabase):
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
@@ -270,7 +280,7 @@ class Neo4jSink(SinkDatabase):
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
|
||||
from api.attack_paths.sink.base import SinkDatabase
|
||||
from api.attack_paths.sink.drop import (
|
||||
NODE_DELETE_QUERY_TEMPLATE,
|
||||
@@ -66,7 +66,7 @@ READ_EXCEPTION_CODES = [
|
||||
"Neo.ClientError.Procedure.ProcedureNotFound",
|
||||
]
|
||||
CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
|
||||
RETRYABLE_WRITE_ERROR_PREFIXES = (
|
||||
RETRYABLE_WRITE_ERROR_FRAGMENTS = (
|
||||
"Operation failed due to conflicting concurrent operations",
|
||||
"Operation terminated (deadline exceeded)",
|
||||
)
|
||||
@@ -78,12 +78,15 @@ SIGV4_TOKEN_LIFETIME_MINUTES = 4
|
||||
def _is_retryable_write_error(exc: Exception) -> bool:
|
||||
if not isinstance(exc, neo4j.exceptions.Neo4jError):
|
||||
return False
|
||||
return bool(exc.message and exc.message.startswith(RETRYABLE_WRITE_ERROR_PREFIXES))
|
||||
message = exc.message or ""
|
||||
return any(fragment in message for fragment in RETRYABLE_WRITE_ERROR_FRAGMENTS)
|
||||
|
||||
|
||||
class NeptuneSink(SinkDatabase):
|
||||
"""Neptune-backed sink. Single database; isolation is label-based."""
|
||||
|
||||
sync_batch_size = env.int("ATTACK_PATHS_NEPTUNE_SYNC_BATCH_SIZE", default=500)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._writer: neo4j.Driver | None = None
|
||||
self._reader: neo4j.Driver | None = None
|
||||
@@ -205,6 +208,7 @@ class NeptuneSink(SinkDatabase):
|
||||
from api.attack_paths.database import (
|
||||
ClientStatementException,
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
WriteQueryNotAllowedException,
|
||||
)
|
||||
|
||||
@@ -226,9 +230,17 @@ class NeptuneSink(SinkDatabase):
|
||||
initial_retry_delay_seconds=(
|
||||
NEPTUNE_WRITE_RETRY_DELAY_SECONDS if is_write_session else 0
|
||||
),
|
||||
retry_context="Neptune write" if is_write_session else None,
|
||||
)
|
||||
yield session_wrapper
|
||||
|
||||
except RetryExhaustedError as exc:
|
||||
last_error = exc.last_error
|
||||
raise NeptuneWriteRetryExhaustedException(
|
||||
message=str(exc),
|
||||
code=getattr(last_error, "code", None),
|
||||
) from last_error
|
||||
|
||||
except neo4j.exceptions.Neo4jError as exc:
|
||||
if (
|
||||
default_access_mode == neo4j.READ_ACCESS
|
||||
@@ -290,7 +302,7 @@ class NeptuneSink(SinkDatabase):
|
||||
graph's branching factor.
|
||||
"""
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
@@ -329,7 +341,7 @@ class NeptuneSink(SinkDatabase):
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
@@ -348,7 +360,7 @@ class NeptuneSink(SinkDatabase):
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from api.attack_paths.queries.schema import (
|
||||
RAW_SCHEMA_URL,
|
||||
get_cartography_schema_query,
|
||||
)
|
||||
from api.attack_paths.sink.base import SinkDatabase
|
||||
from api.models import AttackPathsScan
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.env import env
|
||||
@@ -115,8 +116,27 @@ def execute_query(
|
||||
# TODO: drop after Neptune cutover
|
||||
# Route reads by the scan row's recorded sink, not by current settings.
|
||||
backend = sink_module.get_backend_for_scan(scan)
|
||||
graph = backend.execute_read_query(database_name, definition.cypher, parameters)
|
||||
return _serialize_graph(graph, provider_id)
|
||||
|
||||
cypher = definition.cypher
|
||||
# Every synced node carries a `_Provider_{uuid}` isolation label (the
|
||||
# sync labels the whole provider subgraph). Injecting it into the
|
||||
# predefined query's node patterns gives the planner a selective label
|
||||
# index to seed from instead of a global label scan (`:AWSRole` across
|
||||
# every tenant), which on Neptune is the difference between a sub-second
|
||||
# plan and a query that times out. The custom-query path relies on this
|
||||
# same injection.
|
||||
#
|
||||
# Restrict it to migrated scans: that catalog runs on the Neptune sink
|
||||
# where the plan blowup happens, while the pre-cutover legacy catalog
|
||||
# runs on the old sink and is dropped after the cutover, so leave it
|
||||
# byte-for-byte unchanged. This only affects the query plan, not
|
||||
# isolation - `_serialize_graph` already label-filters both catalogs.
|
||||
# TODO: drop the is_migrated guard after Neptune cutover
|
||||
if scan.is_migrated:
|
||||
cypher = inject_provider_label(cypher, provider_id)
|
||||
|
||||
graph = backend.execute_read_query(database_name, cypher, parameters)
|
||||
return _serialize_graph(graph, provider_id, backend)
|
||||
|
||||
except graph_database.WriteQueryNotAllowedException:
|
||||
raise PermissionDenied(
|
||||
@@ -155,6 +175,8 @@ def execute_custom_query(
|
||||
# 1. `neo4j.READ_ACCESS` - prevents mutations at the driver level
|
||||
# 2. `inject_provider_label()` - regex-based label injection scopes node patterns
|
||||
# 3. `_serialize_graph()` - post-query filter drops nodes without the provider label
|
||||
# and any node or relationship the backend did not store (APOC virtual
|
||||
# entities can carry an arbitrary label and arbitrary properties)
|
||||
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune - server-side runaway cutoff
|
||||
#
|
||||
# Layer 2 is best-effort (regex can't fully parse Cypher);
|
||||
@@ -174,7 +196,7 @@ def execute_custom_query(
|
||||
|
||||
try:
|
||||
graph = backend.execute_read_query(database_name, cypher, None)
|
||||
serialized = _serialize_graph(graph, provider_id)
|
||||
serialized = _serialize_graph(graph, provider_id, backend)
|
||||
return _truncate_graph(serialized)
|
||||
|
||||
except graph_database.ClientStatementException as exc:
|
||||
@@ -246,13 +268,15 @@ def _truncate_graph(graph: dict[str, Any]) -> dict[str, Any]:
|
||||
return graph
|
||||
|
||||
|
||||
def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
|
||||
def _serialize_graph(graph, provider_id: str, backend: SinkDatabase) -> dict[str, Any]:
|
||||
provider_label = get_provider_label(provider_id)
|
||||
|
||||
nodes = []
|
||||
kept_node_ids = set()
|
||||
for node in graph.nodes:
|
||||
if provider_label not in node.labels:
|
||||
if provider_label not in node.labels or not backend.is_persisted_element_id(
|
||||
node.element_id
|
||||
):
|
||||
continue
|
||||
|
||||
kept_node_ids.add(node.element_id)
|
||||
@@ -267,7 +291,7 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
|
||||
filtered_count = len(graph.nodes) - len(nodes)
|
||||
if filtered_count > 0:
|
||||
logger.debug(
|
||||
f"Filtered {filtered_count} nodes without provider label {provider_label}"
|
||||
f"Filtered {filtered_count} nodes without provider label {provider_label} or not stored"
|
||||
)
|
||||
|
||||
relationships = []
|
||||
@@ -275,6 +299,7 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
|
||||
if (
|
||||
relationship.start_node.element_id not in kept_node_ids
|
||||
or relationship.end_node.element_id not in kept_node_ids
|
||||
or not backend.is_persisted_element_id(relationship.element_id)
|
||||
):
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from math import isfinite
|
||||
from uuid import UUID
|
||||
|
||||
@@ -5,6 +7,7 @@ from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey, TenantAPIKeyManager
|
||||
from cryptography.fernet import InvalidToken
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
@@ -14,6 +17,19 @@ from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.request import Request
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Writing on every request makes all requests of a busy key contend on one row
|
||||
API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60
|
||||
|
||||
|
||||
class OrphanedAPIKeyError(Exception):
|
||||
"""Raised when an API key outlived the user that owns it.
|
||||
|
||||
The revocation is written by a plain `update()` before this is raised, so it is
|
||||
already persisted by the time `authenticate` catches it and rejects the request.
|
||||
"""
|
||||
|
||||
|
||||
class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
model = TenantAPIKey
|
||||
@@ -24,10 +40,14 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
def _authenticate_credentials(self, request, key):
|
||||
"""
|
||||
Override to use admin connection, bypassing RLS during authentication.
|
||||
|
||||
Returns the validated API key row from a single read. `authenticate` builds
|
||||
the auth claims from that same row instead of looking it up again, so a key
|
||||
revoked or orphaned right after validation can't still authenticate.
|
||||
"""
|
||||
try:
|
||||
payload = self.key_crypto.decrypt(key)
|
||||
except ValueError:
|
||||
except (ValueError, InvalidToken):
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
@@ -52,13 +72,36 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
raise AuthenticationFailed("API Key has already expired.")
|
||||
|
||||
try:
|
||||
api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk)
|
||||
# Loading `entity` in the same query keeps a user deleted after this read
|
||||
# from turning the later `api_key.entity` access into a 500
|
||||
api_key = (
|
||||
self.model.objects.using(MainRouter.admin_db)
|
||||
.select_related("entity")
|
||||
.get(id=api_key_pk)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
|
||||
if api_key.revoked:
|
||||
raise AuthenticationFailed("This API Key has been revoked.")
|
||||
|
||||
# `entity` is nullable and `on_delete=SET_NULL` leaves the key behind when its
|
||||
# owner is deleted, so a key can outlive its user. Reject it here: further down
|
||||
# the authentication would return `None` as the authenticated user, which blows
|
||||
# up while building the auth dict and surfaces as a 500 instead of a 401.
|
||||
# Revoke it as well, so it stops showing up as active and later attempts fail
|
||||
# the `revoked` check above like any other revoked key.
|
||||
if api_key.entity_id is None:
|
||||
self.model.objects.using(MainRouter.admin_db).filter(
|
||||
id=api_key.id, revoked=False
|
||||
).update(revoked=True)
|
||||
logger.warning(
|
||||
"Revoked orphaned API key: prefix=%s tenant=%s",
|
||||
api_key.prefix,
|
||||
api_key.tenant_id,
|
||||
)
|
||||
raise OrphanedAPIKeyError
|
||||
|
||||
client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER)
|
||||
if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips:
|
||||
raise AuthenticationFailed("Access denied from blacklisted IP.")
|
||||
@@ -66,7 +109,7 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips:
|
||||
raise AuthenticationFailed("Access restricted to specific IP addresses.")
|
||||
|
||||
return api_key.entity, key
|
||||
return api_key
|
||||
|
||||
def authenticate(self, request: Request):
|
||||
prefixed_key = self.get_key(request)
|
||||
@@ -78,36 +121,38 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
try:
|
||||
entity, _ = self._authenticate_credentials(request, key)
|
||||
except InvalidToken:
|
||||
api_key = self._authenticate_credentials(request, key)
|
||||
except OrphanedAPIKeyError:
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
|
||||
# The prefix used to be checked by the second lookup
|
||||
if api_key.prefix != prefix:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# Get the API key instance to update last_used_at and retrieve tenant info
|
||||
# We need to decrypt again to get the pk (already validated by _authenticate_credentials)
|
||||
payload = self.key_crypto.decrypt(key)
|
||||
api_key_pk = payload["_pk"]
|
||||
|
||||
# Convert string UUID back to UUID object for lookup
|
||||
if isinstance(api_key_pk, str):
|
||||
api_key_pk = UUID(api_key_pk)
|
||||
|
||||
try:
|
||||
api_key_instance = TenantAPIKey.objects.using(MainRouter.admin_db).get(
|
||||
id=api_key_pk, prefix=prefix
|
||||
)
|
||||
except TenantAPIKey.DoesNotExist:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# Update last_used_at
|
||||
api_key_instance.last_used_at = timezone.now()
|
||||
api_key_instance.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
|
||||
self._throttled_touch_last_used_at(api_key)
|
||||
|
||||
entity = api_key.entity
|
||||
return entity, {
|
||||
"tenant_id": str(api_key_instance.tenant_id),
|
||||
"sub": str(api_key_instance.entity.id),
|
||||
"api_key_prefix": prefix,
|
||||
"tenant_id": str(api_key.tenant_id),
|
||||
"sub": str(entity.id),
|
||||
"api_key_prefix": api_key.prefix,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None:
|
||||
"""Write `last_used_at` at most once per throttle interval, without locking the row."""
|
||||
now = timezone.now()
|
||||
stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS)
|
||||
|
||||
if api_key.last_used_at is not None and api_key.last_used_at >= stale_before:
|
||||
return
|
||||
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
|
||||
id=api_key.id, revoked=False
|
||||
).filter(
|
||||
Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before)
|
||||
).update(last_used_at=now)
|
||||
|
||||
|
||||
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
|
||||
jwt_auth = JWTAuthentication()
|
||||
|
||||
@@ -3,9 +3,10 @@ from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
|
||||
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
|
||||
from api.filters import CustomDjangoFilterBackend
|
||||
from api.models import Role, UserRoleRelationship
|
||||
from api.rbac.permissions import HasPermissions
|
||||
from api.rbac.permissions import HasPermissions, get_role
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import NotAuthenticated
|
||||
from rest_framework.filters import SearchFilter
|
||||
@@ -100,6 +101,11 @@ class BaseRLSViewSet(BaseViewSet):
|
||||
context["tenant_id"] = self.request.tenant_id
|
||||
return context
|
||||
|
||||
@cached_property
|
||||
def user_role(self):
|
||||
"""Role of the requesting user in the active tenant, resolved once per request."""
|
||||
return get_role(self.request.user, self.request.tenant_id)
|
||||
|
||||
|
||||
class BaseTenantViewset(BaseViewSet):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import ast
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
_UNPARSED = object()
|
||||
|
||||
|
||||
def decode_celery_field(value: Any, default: Any) -> Any:
|
||||
"""Decode a Celery result field and require JSON-serializable output."""
|
||||
decoded = value
|
||||
for _ in range(2):
|
||||
if not isinstance(decoded, str):
|
||||
break
|
||||
|
||||
text = decoded.strip()
|
||||
if not text:
|
||||
decoded = default
|
||||
break
|
||||
|
||||
parsed = _UNPARSED
|
||||
for parser in (json.loads, ast.literal_eval):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
break
|
||||
except (TypeError, ValueError, SyntaxError):
|
||||
continue
|
||||
|
||||
if parsed is _UNPARSED:
|
||||
raise ValueError("Unable to decode Celery result field")
|
||||
decoded = parsed
|
||||
|
||||
decoded = default if decoded is None else decoded
|
||||
try:
|
||||
json.dumps(decoded, allow_nan=False)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(
|
||||
"Decoded Celery result field is not JSON serializable"
|
||||
) from error
|
||||
|
||||
return decoded
|
||||
@@ -1,3 +1,4 @@
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
from django.conf import settings
|
||||
@@ -5,6 +6,7 @@ from django.conf import settings
|
||||
ALLOWED_APPS = ("django", "socialaccount", "account", "authtoken", "silk")
|
||||
|
||||
_read_db_alias = ContextVar("read_db_alias", default=None)
|
||||
_write_db_alias = ContextVar("write_db_alias", default=None)
|
||||
|
||||
|
||||
def set_read_db_alias(alias: str | None):
|
||||
@@ -22,6 +24,30 @@ def reset_read_db_alias(token) -> None:
|
||||
_read_db_alias.reset(token)
|
||||
|
||||
|
||||
def set_write_db_alias(alias: str | None):
|
||||
if not alias:
|
||||
return None
|
||||
return _write_db_alias.set(alias)
|
||||
|
||||
|
||||
def get_write_db_alias() -> str | None:
|
||||
return _write_db_alias.get()
|
||||
|
||||
|
||||
def reset_write_db_alias(token) -> None:
|
||||
if token is not None:
|
||||
_write_db_alias.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def write_db_alias(alias: str | None):
|
||||
token = set_write_db_alias(alias)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_write_db_alias(token)
|
||||
|
||||
|
||||
class MainRouter:
|
||||
default_db = "default"
|
||||
admin_db = "admin"
|
||||
@@ -43,6 +69,9 @@ class MainRouter:
|
||||
model_table_name = model._meta.db_table
|
||||
if any(model_table_name.startswith(f"{app}_") for app in ALLOWED_APPS):
|
||||
return self.admin_db
|
||||
write_alias = get_write_db_alias()
|
||||
if write_alias:
|
||||
return write_alias
|
||||
return None
|
||||
|
||||
def allow_migrate(self, db, app_label, model_name=None, **hints): # noqa: F841
|
||||
|
||||
@@ -409,7 +409,7 @@ def batch_delete(tenant_id, queryset, batch_size=settings.DJANGO_DELETION_BATCH_
|
||||
|
||||
Args:
|
||||
tenant_id (str): Tenant ID the queryset belongs to.
|
||||
queryset (QuerySet): The queryset of objects to delete.
|
||||
queryset: The queryset of objects to delete.
|
||||
batch_size (int): The number of objects to delete in each batch.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import uuid
|
||||
from functools import wraps
|
||||
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import Provider, Scan
|
||||
from api.models import Membership, Provider, Scan, Tenant
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import DatabaseError, connection, transaction
|
||||
from django.db import DEFAULT_DB_ALIAS, DatabaseError, connection, transaction
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
|
||||
@@ -75,9 +76,11 @@ def handle_provider_deletion(func):
|
||||
"""
|
||||
Decorator that raises `ProviderDeletedException` if provider was deleted during execution.
|
||||
|
||||
Catches `ObjectDoesNotExist` and `DatabaseError` (including `IntegrityError`), checks if
|
||||
provider still exists, and raises `ProviderDeletedException` if not. Otherwise,
|
||||
re-raises original exception.
|
||||
Catches `ObjectDoesNotExist`, `DatabaseError` (including `IntegrityError`), and
|
||||
`GraphDatabaseQueryException`, checks if provider still exists, and raises
|
||||
`ProviderDeletedException` if not. Graph database errors also check whether the
|
||||
tenant still exists and has memberships. Otherwise, re-raises the original
|
||||
exception.
|
||||
|
||||
Requires `tenant_id` and `provider_id` in kwargs.
|
||||
|
||||
@@ -92,11 +95,16 @@ def handle_provider_deletion(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (ObjectDoesNotExist, DatabaseError):
|
||||
except (ObjectDoesNotExist, DatabaseError, GraphDatabaseQueryException) as exc:
|
||||
tenant_id = kwargs.get("tenant_id")
|
||||
provider_id = kwargs.get("provider_id")
|
||||
database_alias = (
|
||||
DEFAULT_DB_ALIAS
|
||||
if isinstance(exc, GraphDatabaseQueryException)
|
||||
else READ_REPLICA_ALIAS
|
||||
)
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
with rls_transaction(tenant_id, using=database_alias):
|
||||
if provider_id is None:
|
||||
scan_id = kwargs.get("scan_id")
|
||||
if scan_id is None:
|
||||
@@ -113,6 +121,13 @@ def handle_provider_deletion(func):
|
||||
raise ProviderDeletedException(
|
||||
f"Provider '{provider_id}' was deleted during the scan"
|
||||
) from None
|
||||
if isinstance(exc, GraphDatabaseQueryException) and (
|
||||
not Tenant.objects.filter(pk=tenant_id).exists()
|
||||
or not Membership.objects.filter(tenant_id=tenant_id).exists()
|
||||
):
|
||||
raise ProviderDeletedException(
|
||||
f"Tenant '{tenant_id}' was deleted during the scan"
|
||||
) from None
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
@@ -1439,8 +1439,20 @@ class InvitationFilter(FilterSet):
|
||||
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
|
||||
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
|
||||
expires_at = DateFilter(field_name="expires_at", lookup_expr="date")
|
||||
state = ChoiceFilter(choices=Invitation.State.choices)
|
||||
state__in = ChoiceInFilter(choices=Invitation.State.choices, lookup_expr="in")
|
||||
state = ChoiceFilter(choices=Invitation.State.choices, method="filter_state")
|
||||
state__in = ChoiceInFilter(
|
||||
choices=Invitation.State.choices, lookup_expr="in", method="filter_state_in"
|
||||
)
|
||||
|
||||
def filter_state(self, queryset, name, value):
|
||||
return self.filter_state_in(queryset, name, [value])
|
||||
|
||||
def filter_state_in(self, queryset, name, value):
|
||||
lapsed = Invitation.lapsed_q()
|
||||
query = Q(state__in=value) & ~lapsed
|
||||
if Invitation.State.EXPIRED in value:
|
||||
query |= lapsed
|
||||
return queryset.filter(query)
|
||||
|
||||
class Meta:
|
||||
model = Invitation
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import uuid
|
||||
|
||||
import api.rls
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0097_attack_paths_scan_db_defaults"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TenantOnboardingProfile",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("inserted_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"declared_cloud_accounts",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("1", "1"),
|
||||
("2-10", "2-10"),
|
||||
("11-50", "11-50"),
|
||||
("51-200", "51-200"),
|
||||
("200+", "200+"),
|
||||
],
|
||||
max_length=16,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"declared_role",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("security", "Security"),
|
||||
("devops_platform", "DevOps / Platform"),
|
||||
("developer", "Developer"),
|
||||
("compliance_grc", "Compliance / GRC"),
|
||||
("other", "Other"),
|
||||
],
|
||||
max_length=32,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"declared_seniority",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("practitioner", "Practitioner / IC"),
|
||||
("lead", "Team lead / Manager"),
|
||||
("director", "Director / Head of"),
|
||||
("executive", "VP / C-level"),
|
||||
("founder", "Founder / Owner"),
|
||||
],
|
||||
max_length=32,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("skipped", models.BooleanField(default=False)),
|
||||
(
|
||||
"submitted_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_onboarding_profiles",
|
||||
related_query_name="tenant_onboarding_profile",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "tenant_onboarding_profiles",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="tenantonboardingprofile",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant_id",), name="unique_tenant_onboarding_profile"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="tenantonboardingprofile",
|
||||
# `statements` written out explicitly: RowLevelSecurityConstraint
|
||||
# .deconstruct() does not serialize it, so an autogenerated
|
||||
# migration falls back to ["SELECT"] and leaves the table without
|
||||
# INSERT/UPDATE/DELETE policies.
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_tenantonboardingprofile",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
# The onboarding profile step was reverted after 0098 had been merged, so
|
||||
# the table goes away through a new migration rather than by deleting 0098.
|
||||
dependencies = [
|
||||
("api", "0098_tenant_onboarding_profile"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="TenantOnboardingProfile",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
from django.db import migrations
|
||||
|
||||
TASK_NAME = "attack-paths-reap-orphaned-tmp-databases"
|
||||
INTERVAL_HOURS = 6
|
||||
|
||||
|
||||
def create_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
schedule, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=INTERVAL_HOURS,
|
||||
period="hours",
|
||||
)
|
||||
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=TASK_NAME,
|
||||
defaults={
|
||||
"task": TASK_NAME,
|
||||
"interval": schedule,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def delete_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
PeriodicTask.objects.filter(name=TASK_NAME).delete()
|
||||
|
||||
# Clean up the schedule if no other task references it
|
||||
IntervalSchedule.objects.filter(
|
||||
every=INTERVAL_HOURS,
|
||||
period="hours",
|
||||
periodictask__isnull=True,
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0099_delete_tenant_onboarding_profile"),
|
||||
("django_celery_beat", "0019_alter_periodictasks_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_periodic_task, delete_periodic_task),
|
||||
]
|
||||
@@ -617,9 +617,66 @@ class Task(RowLevelSecurityProtectedModel):
|
||||
resource_name = "tasks"
|
||||
|
||||
|
||||
class ScanQuerySet(models.QuerySet):
|
||||
"""Shared selectors for "the latest scan of a provider".
|
||||
|
||||
The queryset must already be scoped by the caller: manager, tenant, RBAC,
|
||||
providers and database alias.
|
||||
"""
|
||||
|
||||
# How "which completed scan is the provider's current one" is ordered.
|
||||
LATEST_ORDER_BY = (
|
||||
models.F("completed_at").desc(nulls_last=True),
|
||||
models.F("inserted_at").desc(),
|
||||
models.F("id").desc(),
|
||||
)
|
||||
|
||||
def _eligible_for_latest(self) -> "ScanQuerySet":
|
||||
"""Restrict to the scans that may be a provider's latest.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: The completed scans.
|
||||
"""
|
||||
return self.filter(state=StateChoices.COMPLETED)
|
||||
|
||||
def latest_per_provider(self) -> "ScanQuerySet":
|
||||
"""Pick each provider's latest scan with `DISTINCT ON (provider_id)`.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: One scan per provider, the latest one.
|
||||
"""
|
||||
return (
|
||||
self._eligible_for_latest()
|
||||
.order_by("provider_id", *self.LATEST_ORDER_BY)
|
||||
.distinct("provider_id")
|
||||
)
|
||||
|
||||
def latest_ids_per_provider(self) -> list[UUID]:
|
||||
"""Evaluate `latest_per_provider` and return the scan ids.
|
||||
|
||||
The ids are materialised so callers can pass them as a literal `IN`
|
||||
list; as a subquery Postgres misestimates the row count and picks a
|
||||
slow nested loop.
|
||||
|
||||
Returns:
|
||||
list[UUID]: The id of each provider's latest scan.
|
||||
"""
|
||||
return list(self.latest_per_provider().values_list("id", flat=True))
|
||||
|
||||
def latest_first(self) -> "ScanQuerySet":
|
||||
"""Order eligible scans newest first, without deduplicating per provider.
|
||||
|
||||
Expects the queryset to be already filtered to a single provider.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: The eligible scans, latest first.
|
||||
"""
|
||||
return self._eligible_for_latest().order_by(*self.LATEST_ORDER_BY)
|
||||
|
||||
|
||||
class Scan(RowLevelSecurityProtectedModel):
|
||||
objects = ActiveProviderManager()
|
||||
all_objects = models.Manager()
|
||||
objects = ActiveProviderManager.from_queryset(ScanQuerySet)()
|
||||
all_objects = ScanQuerySet.as_manager()
|
||||
|
||||
_SCOPING_SCANNER_ARG_KEYS_CACHE: tuple[str, ...] | None = None
|
||||
|
||||
@@ -726,6 +783,12 @@ class Scan(RowLevelSecurityProtectedModel):
|
||||
name="scans_prov_state_ins_desc_idx",
|
||||
),
|
||||
# TODO This might replace `scans_prov_state_ins_desc_idx` completely. Review usage
|
||||
# Since `ScanQuerySet`, no code path reads a provider's
|
||||
# completed scans by `-inserted_at`. The only query left that
|
||||
# matches this index (and `scans_prov_state_ins_desc_idx` above)
|
||||
# is `GET /scans?filter[provider]=…&filter[state]=completed` with
|
||||
# the default sort. Both are candidates to drop in a follow-up
|
||||
# once production `pg_stat_user_indexes.idx_scan` confirms it.
|
||||
models.Index(
|
||||
fields=["tenant_id", "provider_id", "-inserted_at"],
|
||||
condition=Q(state=StateChoices.COMPLETED),
|
||||
@@ -1380,6 +1443,15 @@ class Invitation(RowLevelSecurityProtectedModel):
|
||||
self.email = self.email.strip().lower()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def lapsed_q(cls):
|
||||
"""Pending invitations whose expiry date has already passed."""
|
||||
return Q(state=cls.State.PENDING, expires_at__lte=datetime.now(UTC))
|
||||
|
||||
@property
|
||||
def is_lapsed(self):
|
||||
return self.state == self.State.PENDING and self.expires_at <= datetime.now(UTC)
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "invitations"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.rls import RowLevelSecurityConstraint
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from psqlextra.partitioning import (
|
||||
PostgresPartitioningError,
|
||||
PostgresPartitioningManager,
|
||||
@@ -153,10 +154,17 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
|
||||
)
|
||||
|
||||
|
||||
def relative_days_or_none(value):
|
||||
if value is None:
|
||||
def relative_months_or_none(value):
|
||||
# A negative value would set the cutoff in the future and delete every
|
||||
# partition, so it is rejected rather than silently ignored.
|
||||
if value is not None and value < 0:
|
||||
raise ImproperlyConfigured(
|
||||
"FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS must not be negative; "
|
||||
"leave it unset or use 0 to keep partitions indefinitely"
|
||||
)
|
||||
if not value:
|
||||
return None
|
||||
return relativedelta(days=value)
|
||||
return relativedelta(months=value)
|
||||
|
||||
|
||||
#
|
||||
@@ -173,7 +181,7 @@ manager = PostgresPartitioningManager(
|
||||
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
|
||||
),
|
||||
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
|
||||
max_age=relative_days_or_none(
|
||||
max_age=relative_months_or_none(
|
||||
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
|
||||
),
|
||||
name_format="%Y_%b",
|
||||
@@ -189,7 +197,7 @@ manager = PostgresPartitioningManager(
|
||||
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
|
||||
),
|
||||
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
|
||||
max_age=relative_days_or_none(
|
||||
max_age=relative_months_or_none(
|
||||
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
|
||||
),
|
||||
name_format="%Y_%b",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Provider, Role, User
|
||||
from django.db.models import QuerySet
|
||||
from api.models import Integration, Provider, Role, Task, User
|
||||
from django.db.models import Q, QuerySet
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
@@ -17,6 +17,50 @@ class Permissions(Enum):
|
||||
UNLIMITED_VISIBILITY = "unlimited_visibility"
|
||||
|
||||
|
||||
# Revoking a task needs the permission of the operation that queued it.
|
||||
# None and unmapped names are not revocable; a revoked provider deletion
|
||||
# would leave the provider soft-deleted with nothing re-queuing the cleanup.
|
||||
TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = {
|
||||
"provider-connection-check": [Permissions.MANAGE_PROVIDERS],
|
||||
"provider-deletion": None,
|
||||
"integration-connection-check": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-s3": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-security-hub": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-jira": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"scan-perform": [Permissions.MANAGE_SCANS],
|
||||
"scan-perform-scheduled": [Permissions.MANAGE_SCANS],
|
||||
"scan-compliance-overviews": [Permissions.MANAGE_SCANS],
|
||||
"scan-compliance-reports": [Permissions.MANAGE_SCANS],
|
||||
"scan-finding-group-summaries": [Permissions.MANAGE_SCANS],
|
||||
"scan-report": [Permissions.MANAGE_SCANS],
|
||||
"attack-paths-scan-perform": [Permissions.MANAGE_SCANS],
|
||||
"findings-mute-latest-scans": [Permissions.MANAGE_SCANS],
|
||||
"lighthouse-connection-check": [],
|
||||
"lighthouse-provider-connection-check": [],
|
||||
"lighthouse-provider-models-refresh": [],
|
||||
}
|
||||
|
||||
|
||||
def get_user_roles(user: User, tenant_id: str) -> list[Role]:
|
||||
"""Return every role assigned to the user in the tenant."""
|
||||
return list(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id)
|
||||
)
|
||||
|
||||
|
||||
def roles_have_permissions(
|
||||
roles: list[Role], required_permissions: list[Permissions]
|
||||
) -> bool:
|
||||
"""Return True when every required permission is granted by at least one role."""
|
||||
return all(
|
||||
any(getattr(role, permission.value, False) for role in roles)
|
||||
for permission in required_permissions
|
||||
)
|
||||
|
||||
|
||||
class HasPermissions(BasePermission):
|
||||
"""
|
||||
Custom permission to check if the user's role has the required permissions.
|
||||
@@ -34,19 +78,11 @@ class HasPermissions(BasePermission):
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
user_roles = list(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=request.user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id)
|
||||
)
|
||||
user_roles = get_user_roles(request.user, tenant_id)
|
||||
if not user_roles:
|
||||
return False
|
||||
|
||||
return all(
|
||||
any(getattr(role, permission.value, False) for role in user_roles)
|
||||
for permission in required_permissions
|
||||
)
|
||||
return roles_have_permissions(user_roles, required_permissions)
|
||||
|
||||
|
||||
def get_role(user: User, tenant_id: str) -> Role:
|
||||
@@ -83,3 +119,51 @@ def get_providers(role: Role) -> QuerySet[Provider]:
|
||||
return Provider.objects.filter(
|
||||
tenant_id=tenant_id, provider_groups__in=provider_groups
|
||||
).distinct()
|
||||
|
||||
|
||||
def get_tasks(role: Role) -> QuerySet[Task]:
|
||||
"""Return the tasks visible to the role: tenant-wide ones and those of its providers."""
|
||||
queryset = Task.objects.filter(tenant_id=role.tenant_id)
|
||||
if role.unlimited_visibility:
|
||||
return queryset
|
||||
|
||||
# Task has no provider FK, so match provider ids inside the stored kwargs.
|
||||
# all_objects keeps a soft-deleted provider visible to its own groups, so the
|
||||
# role that queued its deletion can still follow the task.
|
||||
hidden = Q()
|
||||
for provider_id in (
|
||||
Provider.all_objects.filter(tenant_id=role.tenant_id)
|
||||
.exclude(provider_groups__in=role.provider_groups.all())
|
||||
.values_list("id", flat=True)
|
||||
):
|
||||
hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id))
|
||||
return queryset.exclude(hidden) if hidden else queryset
|
||||
|
||||
|
||||
def get_integrations(
|
||||
role: Role, providers: QuerySet[Provider] | None = None
|
||||
) -> QuerySet[Integration]:
|
||||
"""
|
||||
Return a distinct queryset of Integrations visible to the given role.
|
||||
|
||||
Integrations with no providers attached are tenant-wide, as is always the case for
|
||||
Jira, and stay visible regardless of the provider visibility of the role. Integrations
|
||||
attached to providers are only visible when the role can access at least one of them.
|
||||
|
||||
Args:
|
||||
role: A Role instance.
|
||||
providers: Optional queryset of the providers accessible by the role, to reuse
|
||||
an already resolved `get_providers(role)` result within the same request.
|
||||
|
||||
Returns:
|
||||
A QuerySet of Integration objects visible to the role.
|
||||
"""
|
||||
queryset = Integration.objects.filter(tenant_id=role.tenant_id)
|
||||
if role.unlimited_visibility:
|
||||
return queryset
|
||||
|
||||
if providers is None:
|
||||
providers = get_providers(role)
|
||||
return queryset.filter(
|
||||
Q(providers__isnull=True) | Q(providers__in=providers)
|
||||
).distinct()
|
||||
@@ -1,3 +1,4 @@
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import delete_related_daily_task
|
||||
from api.models import (
|
||||
LighthouseProviderConfiguration,
|
||||
@@ -47,8 +48,15 @@ def revoke_user_api_keys(sender, instance, **kwargs): # noqa: F841
|
||||
|
||||
The entity field will be set to NULL by on_delete=SET_NULL,
|
||||
but we explicitly revoke the keys to prevent further use.
|
||||
|
||||
The update runs on the admin connection because `api_keys` is RLS protected and its
|
||||
policy denies every row when `api.tenant_id` is unset. Users are deleted through the
|
||||
admin connection and may belong to several tenants, so going through the default
|
||||
connection would silently revoke nothing, or only the keys of the active tenant.
|
||||
"""
|
||||
TenantAPIKey.objects.filter(entity=instance).update(revoked=True)
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(entity=instance).update(
|
||||
revoked=True
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Membership)
|
||||
@@ -58,8 +66,12 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
|
||||
|
||||
When a membership is deleted, all API keys created by that user
|
||||
in that tenant should be revoked to prevent further access.
|
||||
|
||||
Uses the admin connection for the same reason as `revoke_user_api_keys`: the RLS
|
||||
policy on `api_keys` denies every row when `api.tenant_id` is unset, which is the
|
||||
case when the membership is removed as a cascade of a user deletion.
|
||||
"""
|
||||
TenantAPIKey.objects.filter(
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
|
||||
entity_id=instance.user_id, tenant_id=instance.tenant_id
|
||||
).update(revoked=True)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.35.0
|
||||
version: 1.49.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
@@ -6629,8 +6629,10 @@ paths:
|
||||
/api/v1/integrations:
|
||||
get:
|
||||
operationId: api_v1_integrations_list
|
||||
description: Retrieve a list of all configured integrations with options for
|
||||
filtering by various criteria.
|
||||
description: |-
|
||||
Retrieve a list of all configured integrations with options for filtering by various criteria.
|
||||
|
||||
Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role.
|
||||
summary: List all integrations
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6781,7 +6783,8 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_create
|
||||
description: Register a new integration with the system, providing necessary
|
||||
configuration details.
|
||||
configuration details. Only providers visible to the role can be attached
|
||||
to the integration.
|
||||
summary: Create a new integration
|
||||
tags:
|
||||
- Integration
|
||||
@@ -6810,7 +6813,7 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_jira_dispatches_create
|
||||
description: |-
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided.
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
@@ -6883,7 +6886,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_jira_issue_types_retrieve
|
||||
description: Fetch the available issue types from Jira for a given project key
|
||||
and update the integration configuration.
|
||||
and update the integration configuration. Jira integrations are tenant-wide
|
||||
and do not require unlimited visibility.
|
||||
summary: Get available issue types for a Jira project
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6924,7 +6928,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_retrieve
|
||||
description: Fetch detailed information about a specific integration by its
|
||||
ID.
|
||||
ID. Integrations outside the provider visibility of the role are reported
|
||||
the same way as one that does not exist.
|
||||
summary: Retrieve integration details
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6978,7 +6983,8 @@ paths:
|
||||
patch:
|
||||
operationId: api_v1_integrations_partial_update
|
||||
description: Modify certain fields of an existing integration without affecting
|
||||
other settings.
|
||||
other settings. Integrations attached to providers outside the visibility
|
||||
of the role cannot be modified by it.
|
||||
summary: Partially update an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7013,7 +7019,8 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: api_v1_integrations_destroy
|
||||
description: Remove an integration from the system by its ID.
|
||||
description: Remove an integration from the system by its ID. Integrations attached
|
||||
to providers outside the visibility of the role cannot be deleted by it.
|
||||
summary: Delete an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7033,7 +7040,9 @@ paths:
|
||||
/api/v1/integrations/{id}/connection:
|
||||
post:
|
||||
operationId: api_v1_integrations_connection_create
|
||||
description: Try to verify integration connection
|
||||
description: Try to verify integration connection. Integrations outside the
|
||||
provider visibility of the role are reported the same way as one that does
|
||||
not exist.
|
||||
summary: Check integration connection
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -14814,7 +14823,9 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_tasks_list
|
||||
description: Retrieve a list of all tasks with options for filtering by name,
|
||||
state, and other criteria.
|
||||
state, and other criteria. Tasks that reference a provider are only returned
|
||||
when the role can access it; tasks without a provider reference are returned
|
||||
for every role.
|
||||
summary: List all tasks
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -14913,7 +14924,8 @@ paths:
|
||||
/api/v1/tasks/{id}:
|
||||
get:
|
||||
operationId: api_v1_tasks_retrieve
|
||||
description: Fetch detailed information about a specific task by its ID.
|
||||
description: Fetch detailed information about a specific task by its ID. Tasks
|
||||
tied to a provider outside the visibility of the role are not found.
|
||||
summary: Retrieve data from a specific task
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -14954,7 +14966,9 @@ paths:
|
||||
delete:
|
||||
operationId: api_v1_tasks_destroy
|
||||
description: Try to revoke a task using its ID. Only tasks that are not yet
|
||||
in progress can be revoked.
|
||||
in progress can be revoked, and the caller needs the same permission as the
|
||||
operation that queued the task (for example MANAGE_SCANS for a scan). Provider
|
||||
deletions cannot be revoked.
|
||||
summary: Revoke a task
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
|
||||
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
|
||||
from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
|
||||
from django.db.utils import ConnectionDoesNotExist
|
||||
from django.urls import reverse
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.token_blacklist.models import (
|
||||
BlacklistedToken,
|
||||
@@ -524,7 +528,7 @@ class TestAPIKeyAuthentication:
|
||||
def test_last_used_at_tracking(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Verify last_used_at timestamp updates on each authentication."""
|
||||
"""Verify last_used_at timestamp is set on first use and throttled after that."""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
@@ -533,7 +537,11 @@ class TestAPIKeyAuthentication:
|
||||
|
||||
# Use API key to authenticate
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
start = datetime.now(UTC)
|
||||
with freeze_time(start):
|
||||
first_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
assert first_response.status_code == 200
|
||||
|
||||
# Reload from database and check last_used_at is set
|
||||
@@ -541,17 +549,23 @@ class TestAPIKeyAuthentication:
|
||||
first_used_at = api_key.last_used_at
|
||||
assert first_used_at is not None
|
||||
|
||||
# Use the same key again after a small delay
|
||||
time.sleep(0.1)
|
||||
|
||||
# Using the same key again within the throttle interval does not rewrite it
|
||||
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert second_response.status_code == 200
|
||||
|
||||
# Reload and verify last_used_at was updated
|
||||
api_key.refresh_from_db()
|
||||
second_used_at = api_key.last_used_at
|
||||
assert second_used_at is not None
|
||||
assert second_used_at > first_used_at
|
||||
assert api_key.last_used_at == first_used_at
|
||||
|
||||
# Past the throttle interval, the next use refreshes it
|
||||
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
|
||||
with freeze_time(later):
|
||||
third_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
assert third_response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at > first_used_at
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -625,6 +639,34 @@ class TestAPIKeyErrors:
|
||||
assert response.status_code == 401
|
||||
assert "API Key has been revoked." in response.json()["errors"][0]["detail"]
|
||||
|
||||
def test_orphaned_api_key_rejected(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Key whose owning user was deleted returns 401 instead of 500."""
|
||||
client = APIClient()
|
||||
|
||||
api_key = api_keys_fixture[0]
|
||||
# `on_delete=SET_NULL` leaves the key behind with no entity when the owner goes
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert (
|
||||
"No entity matching this api key." in response.json()["errors"][0]["detail"]
|
||||
)
|
||||
|
||||
# The orphaned key is revoked on use; retries fail the regular revoked check
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.revoked is True
|
||||
|
||||
retry_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert retry_response.status_code == 401
|
||||
assert (
|
||||
"API Key has been revoked." in retry_response.json()["errors"][0]["detail"]
|
||||
)
|
||||
|
||||
def test_non_existent_api_key(self, create_test_user, tenants_fixture):
|
||||
"""Key UUID doesn't exist in database."""
|
||||
client = APIClient()
|
||||
@@ -817,6 +859,93 @@ class TestAPIKeyTenantIsolation:
|
||||
error_detail = response_json["errors"][0]["detail"]
|
||||
assert "revoked" in error_detail.lower()
|
||||
|
||||
def test_deleting_user_revokes_api_keys_in_every_tenant(self, tenants_fixture):
|
||||
"""Deleting a user revokes their keys in all their tenants, not just one."""
|
||||
first_tenant, second_tenant = tenants_fixture[0], tenants_fixture[1]
|
||||
|
||||
test_user = User.objects.create_user(
|
||||
name="multi_tenant_user",
|
||||
email="multi_tenant_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
for tenant in (first_tenant, second_tenant):
|
||||
Membership.objects.create(
|
||||
user=test_user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
first_key, _ = TenantAPIKey.objects.create_api_key(
|
||||
name="Key in first tenant", tenant_id=first_tenant.id, entity=test_user
|
||||
)
|
||||
second_key, _ = TenantAPIKey.objects.create_api_key(
|
||||
name="Key in second tenant", tenant_id=second_tenant.id, entity=test_user
|
||||
)
|
||||
|
||||
test_user.delete()
|
||||
|
||||
first_key.refresh_from_db()
|
||||
second_key.refresh_from_db()
|
||||
assert first_key.revoked is True
|
||||
assert second_key.revoked is True
|
||||
# `on_delete=SET_NULL` orphans the keys, so revoking them is what keeps them
|
||||
# from authenticating
|
||||
assert first_key.entity_id is None
|
||||
assert second_key.entity_id is None
|
||||
|
||||
def test_revoke_user_api_keys_uses_the_admin_connection(
|
||||
self, monkeypatch, tenants_fixture
|
||||
):
|
||||
"""The revocation must not go through the default connection.
|
||||
|
||||
`api_keys` is RLS protected and its policy denies every row when `api.tenant_id`
|
||||
is unset, which is the case while a user is deleted through the admin
|
||||
connection: the update would silently revoke nothing and leave usable orphaned
|
||||
keys behind.
|
||||
|
||||
Pointing `admin_db` at a missing alias is the only way to assert the connection
|
||||
here, because the test suite runs on a single superuser database with
|
||||
`MainRouter.admin_db` patched to "default" (see `conftest.py`), so RLS never
|
||||
applies and both connections are otherwise indistinguishable.
|
||||
"""
|
||||
test_user = User.objects.create_user(
|
||||
name="admin_connection_user",
|
||||
email="admin_connection_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(user=test_user, tenant=tenants_fixture[0])
|
||||
TenantAPIKey.objects.create_api_key(
|
||||
name="Key for admin connection check",
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
entity=test_user,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
|
||||
|
||||
with pytest.raises(ConnectionDoesNotExist):
|
||||
revoke_user_api_keys(sender=User, instance=test_user)
|
||||
|
||||
def test_revoke_membership_api_keys_uses_the_admin_connection(
|
||||
self, monkeypatch, tenants_fixture
|
||||
):
|
||||
"""Same as the user deletion case: this receiver also runs as its cascade."""
|
||||
test_user = User.objects.create_user(
|
||||
name="admin_connection_membership_user",
|
||||
email="admin_connection_membership_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
membership = Membership.objects.create(
|
||||
user=test_user, tenant=tenants_fixture[0]
|
||||
)
|
||||
TenantAPIKey.objects.create_api_key(
|
||||
name="Key for membership admin connection check",
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
entity=test_user,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
|
||||
|
||||
with pytest.raises(ConnectionDoesNotExist):
|
||||
revoke_membership_api_keys(sender=Membership, instance=membership)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAPIKeyLifecycle:
|
||||
@@ -1323,6 +1452,7 @@ class TestAPIKeyRLSBypass:
|
||||
|
||||
The update to last_used_at during authentication must also use the
|
||||
admin database since it occurs before RLS context is established.
|
||||
Past the throttle interval, using the key again refreshes the timestamp.
|
||||
"""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
@@ -1330,7 +1460,11 @@ class TestAPIKeyRLSBypass:
|
||||
assert api_key.last_used_at is None
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
start = datetime.now(UTC)
|
||||
with freeze_time(start):
|
||||
first_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
|
||||
@@ -1338,9 +1472,11 @@ class TestAPIKeyRLSBypass:
|
||||
first_timestamp = api_key.last_used_at
|
||||
assert first_timestamp is not None
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
|
||||
with freeze_time(later):
|
||||
second_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
assert second_response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
@@ -1472,8 +1608,8 @@ class TestAPIKeyMultiTenantWorkflows:
|
||||
tenant1 = tenants_fixture[0]
|
||||
tenant2 = tenants_fixture[1]
|
||||
|
||||
Membership.objects.create(user=user, tenant=tenant1)
|
||||
Membership.objects.create(user=user, tenant=tenant2)
|
||||
membership1 = Membership.objects.create(user=user, tenant=tenant1)
|
||||
membership2 = Membership.objects.create(user=user, tenant=tenant2)
|
||||
|
||||
role1 = Role.objects.create(
|
||||
tenant_id=tenant1.id,
|
||||
@@ -1528,6 +1664,27 @@ class TestAPIKeyMultiTenantWorkflows:
|
||||
assert me_response1.json()["data"]["id"] == str(user.id)
|
||||
assert me_response2.json()["data"]["id"] == str(user.id)
|
||||
|
||||
memberships1 = {
|
||||
item["id"]: item["meta"]["active"]
|
||||
for item in me_response1.json()["data"]["relationships"]["memberships"][
|
||||
"data"
|
||||
]
|
||||
}
|
||||
memberships2 = {
|
||||
item["id"]: item["meta"]["active"]
|
||||
for item in me_response2.json()["data"]["relationships"]["memberships"][
|
||||
"data"
|
||||
]
|
||||
}
|
||||
assert memberships1 == {
|
||||
str(membership1.id): True,
|
||||
str(membership2.id): False,
|
||||
}
|
||||
assert memberships2 == {
|
||||
str(membership1.id): False,
|
||||
str(membership2.id): True,
|
||||
}
|
||||
|
||||
def test_api_key_cannot_access_different_tenant_resources(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
|
||||
@@ -2,11 +2,20 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from allauth.account import app_settings as account_app_settings
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.core import context
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount import app_settings as socialaccount_app_settings
|
||||
from allauth.socialaccount.internal.flows.login import complete_login
|
||||
from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
from api.adapters import ProwlerSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.db_router import MainRouter, get_write_db_alias
|
||||
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.db import connections
|
||||
from django.db import router as django_router
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -40,6 +49,7 @@ def _saml_request(rf, organization_slug):
|
||||
def _saml_sociallogin(user):
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "saml"
|
||||
sociallogin.account.extra_data = {}
|
||||
@@ -48,6 +58,163 @@ def _saml_sociallogin(user):
|
||||
return sociallogin
|
||||
|
||||
|
||||
def _oauth_sociallogin(
|
||||
user,
|
||||
*,
|
||||
provider="google",
|
||||
provider_email_verified=True,
|
||||
include_extra_email=True,
|
||||
):
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = provider
|
||||
sociallogin.account.extra_data = (
|
||||
{"email": user.email} if include_extra_email else {}
|
||||
)
|
||||
sociallogin.email_addresses = [
|
||||
EmailAddress(
|
||||
email=user.email,
|
||||
verified=provider_email_verified,
|
||||
primary=True,
|
||||
)
|
||||
]
|
||||
sociallogin.user = user
|
||||
sociallogin.connect = MagicMock()
|
||||
return sociallogin
|
||||
|
||||
|
||||
def _real_oauth_sociallogin(user, uid):
|
||||
provider = MagicMock()
|
||||
provider.id = "google"
|
||||
provider.app = None
|
||||
provider.get_settings.return_value = {}
|
||||
return SocialLogin(
|
||||
user=user,
|
||||
account=SocialAccount(
|
||||
provider="google",
|
||||
uid=uid,
|
||||
extra_data={"email": user.email},
|
||||
),
|
||||
email_addresses=[EmailAddress(email=user.email, verified=True, primary=True)],
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
def _verify_local_email(user):
|
||||
return EmailAddress.objects.create(
|
||||
user=user,
|
||||
email=user.email,
|
||||
verified=True,
|
||||
primary=True,
|
||||
)
|
||||
|
||||
|
||||
def test_social_account_name_falls_back_to_login_for_blank_name():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
name = adapter._get_social_account_name(
|
||||
{"name": " ", "login": "octocat"},
|
||||
"verified@example.com",
|
||||
)
|
||||
|
||||
assert name == "octocat"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]])
|
||||
def test_social_account_name_ignores_unusable_provider_names(provider_name):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
name = adapter._get_social_account_name(
|
||||
{"name": provider_name, "login": "octocat"},
|
||||
"verified@example.com",
|
||||
)
|
||||
|
||||
assert name == "octocat"
|
||||
|
||||
|
||||
def test_social_account_name_uses_login_when_name_is_missing():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
name = adapter._get_social_account_name(
|
||||
{"login": "octocat"},
|
||||
"verified@example.com",
|
||||
)
|
||||
|
||||
assert name == "octocat"
|
||||
|
||||
|
||||
def test_social_account_name_falls_back_to_username_then_email():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
username_name = adapter._get_social_account_name(
|
||||
{"name": "ab", "login": None, "username": " monalisa "},
|
||||
"verified@example.com",
|
||||
)
|
||||
email_name = adapter._get_social_account_name({}, " verified@example.com ")
|
||||
|
||||
assert username_name == "monalisa"
|
||||
assert email_name == "verified@example.com"
|
||||
|
||||
|
||||
def test_social_account_name_trims_and_limits_provider_name():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
max_length = User._meta.get_field("name").max_length
|
||||
|
||||
trimmed_name = adapter._get_social_account_name(
|
||||
{"name": " Ada Lovelace "},
|
||||
"verified@example.com",
|
||||
)
|
||||
limited_name = adapter._get_social_account_name(
|
||||
{"name": "a" * (max_length + 1)},
|
||||
"verified@example.com",
|
||||
)
|
||||
|
||||
assert trimmed_name == "Ada Lovelace"
|
||||
assert limited_name == "a" * max_length
|
||||
|
||||
|
||||
def test_social_account_name_rejects_missing_identity():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Social account does not provide a valid user identity",
|
||||
):
|
||||
adapter._get_social_account_name({}, "")
|
||||
|
||||
|
||||
def test_save_user_applies_normalized_social_account_name(rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "github"
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.extra_data = {"name": None, "login": " octocat "}
|
||||
user = User(email="verified@example.com")
|
||||
user.save = MagicMock()
|
||||
invitation = SimpleNamespace(tenant_id="tenant-id")
|
||||
|
||||
with (
|
||||
patch("api.adapters.super") as mock_super,
|
||||
patch("api.adapters.transaction.atomic"),
|
||||
patch("api.adapters.write_db_alias"),
|
||||
patch.object(adapter, "_get_invitation_token", return_value="token"),
|
||||
patch(
|
||||
"api.adapters.accept_invitation_for_user",
|
||||
return_value=(invitation, True),
|
||||
),
|
||||
):
|
||||
mock_super.return_value.save_user.return_value = user
|
||||
saved_user = adapter.save_user(request, sociallogin)
|
||||
|
||||
assert saved_user.name == "octocat"
|
||||
assert request.prowler_invitation_token == "token"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProwlerSocialAccountAdapter:
|
||||
def test_get_user_by_email_returns_user(self, create_test_user):
|
||||
@@ -157,6 +324,7 @@ class TestProwlerSocialAccountAdapter:
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.user = MagicMock()
|
||||
sociallogin.user.email = ""
|
||||
@@ -168,25 +336,119 @@ class TestProwlerSocialAccountAdapter:
|
||||
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_non_saml_links_by_email(self, create_test_user, rf):
|
||||
"""Non-SAML providers (e.g. Google/GitHub) still link to an existing
|
||||
local account by email; the tenant binding only applies to SAML."""
|
||||
def test_pre_social_login_blocks_unverified_local_email(self, create_test_user, rf):
|
||||
"""A verified OAuth email must not claim an unverified local account."""
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "google"
|
||||
sociallogin.account.extra_data = {"email": create_test_user.email}
|
||||
sociallogin.user = create_test_user
|
||||
sociallogin.connect = MagicMock()
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_complete_oauth_login_does_not_link_unverified_local_email(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""Regression test for the complete pre-hijack account-linking flow."""
|
||||
incoming_user = User(email=create_test_user.email)
|
||||
incoming_user.set_unusable_password()
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
incoming_user,
|
||||
uid="victim-google-account",
|
||||
)
|
||||
request = rf.get("/")
|
||||
request.session = {}
|
||||
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
complete_login(request, sociallogin, raises=True)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
assert not SocialAccount.objects.filter(
|
||||
provider="google", uid="victim-google-account"
|
||||
).exists()
|
||||
|
||||
def test_pre_social_login_allows_already_connected_account(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""Existing provider bindings do not need to relink on every login."""
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
sociallogin.account.pk = "existing-social-account"
|
||||
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
call_args = sociallogin.connect.call_args
|
||||
assert call_args is not None
|
||||
_, called_user = call_args[0]
|
||||
assert called_user.email == create_test_user.email
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_blocks_unverified_provider_email(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""An OAuth provider must prove ownership of the matching email."""
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(
|
||||
create_test_user,
|
||||
provider="github",
|
||||
provider_email_verified=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_links_verified_emails(self, create_test_user, rf):
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
request = rf.get("/")
|
||||
|
||||
adapter.pre_social_login(request, sociallogin)
|
||||
|
||||
sociallogin.connect.assert_called_once_with(request, create_test_user)
|
||||
|
||||
def test_verified_social_account_link_does_not_send_notification(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
_verify_local_email(create_test_user)
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
create_test_user,
|
||||
uid="verified-google-account",
|
||||
)
|
||||
|
||||
request = rf.get("/")
|
||||
with context.request_context(request):
|
||||
ProwlerSocialAccountAdapter().pre_social_login(request, sociallogin)
|
||||
|
||||
assert SocialAccount.objects.filter(
|
||||
provider="google",
|
||||
uid="verified-google-account",
|
||||
user=create_test_user,
|
||||
).exists()
|
||||
assert mail.outbox == []
|
||||
|
||||
def test_pre_social_login_uses_verified_email_missing_from_extra_data(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""GitHub can return its verified primary email outside extra_data."""
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(
|
||||
create_test_user,
|
||||
provider="github",
|
||||
include_extra_email=False,
|
||||
)
|
||||
request = rf.get("/")
|
||||
|
||||
adapter.pre_social_login(request, sociallogin)
|
||||
|
||||
sociallogin.connect.assert_called_once_with(request, create_test_user)
|
||||
|
||||
def test_social_account_linking_settings_are_fail_closed(self):
|
||||
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION
|
||||
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION_AUTO_CONNECT
|
||||
assert not account_app_settings.EMAIL_NOTIFICATIONS
|
||||
|
||||
def test_save_user_social_with_invitation_joins_invited_tenant(
|
||||
self, rf, create_test_user, tenants_fixture
|
||||
@@ -226,6 +488,65 @@ class TestProwlerSocialAccountAdapter:
|
||||
role=Membership.RoleChoices.MEMBER,
|
||||
).exists()
|
||||
|
||||
def test_save_user_routes_initial_allauth_write_to_admin_and_resets_on_error(
|
||||
self, rf
|
||||
):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.get("/")
|
||||
request.session = {}
|
||||
sociallogin = _oauth_sociallogin(
|
||||
User(name="Frank", email="frank-routing@example.com")
|
||||
)
|
||||
|
||||
def fail_after_checking_write_route(*_args, **_kwargs):
|
||||
assert (
|
||||
MainRouter().db_for_write(User, instance=sociallogin.user)
|
||||
== MainRouter.admin_db
|
||||
)
|
||||
raise RuntimeError("Stop after checking the write route.")
|
||||
|
||||
with (
|
||||
patch("api.adapters.super") as mock_super,
|
||||
patch("api.adapters.transaction.atomic"),
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
pytest.raises(RuntimeError, match="Stop after checking the write route"),
|
||||
):
|
||||
mock_super.return_value.save_user.side_effect = (
|
||||
fail_after_checking_write_route
|
||||
)
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_rolls_back_all_signup_records_on_downstream_error(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-rollback@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-rollback-google-account",
|
||||
)
|
||||
tenants_before = Tenant.objects.count()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.adapters.rls_transaction",
|
||||
side_effect=RuntimeError("Simulated downstream failure."),
|
||||
),
|
||||
pytest.raises(RuntimeError, match="Simulated downstream failure"),
|
||||
):
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert not User.objects.filter(email=email).exists()
|
||||
assert not SocialAccount.objects.filter(
|
||||
provider="google",
|
||||
uid="frank-rollback-google-account",
|
||||
).exists()
|
||||
assert not EmailAddress.objects.filter(email=email).exists()
|
||||
assert Tenant.objects.count() == tenants_before
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_saml_sets_session_flag(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.get("/")
|
||||
@@ -246,3 +567,104 @@ class TestProwlerSocialAccountAdapter:
|
||||
mock_super.return_value.save_user.return_value = mock_user
|
||||
adapter.save_user(request, sociallogin)
|
||||
assert request.session["saml_user_created"] == "123"
|
||||
|
||||
|
||||
@pytest.mark.requires_test_admin_alias
|
||||
@pytest.mark.django_db(transaction=True, databases=["default", "admin"])
|
||||
class TestProwlerSocialAccountAdapterMultiDatabase:
|
||||
@staticmethod
|
||||
def _production_router():
|
||||
return patch.object(django_router, "routers", [MainRouter()])
|
||||
|
||||
def test_save_user_rolls_back_across_production_database_aliases(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-multidb-rollback@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-multidb-rollback-google-account",
|
||||
)
|
||||
tenants_before = Tenant.objects.using("admin").count()
|
||||
|
||||
assert connections["default"] is not connections["admin"]
|
||||
assert (
|
||||
connections["default"].settings_dict["NAME"]
|
||||
== connections["admin"].settings_dict["NAME"]
|
||||
)
|
||||
|
||||
def fail_after_allauth_save(*_args, **_kwargs):
|
||||
assert sociallogin.user._state.db == MainRouter.admin_db
|
||||
assert connections["default"].get_autocommit()
|
||||
assert not connections["admin"].get_autocommit()
|
||||
raise RuntimeError("Simulated downstream failure.")
|
||||
|
||||
with (
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
self._production_router(),
|
||||
patch("api.adapters.rls_transaction", side_effect=fail_after_allauth_save),
|
||||
pytest.raises(RuntimeError, match="Simulated downstream failure"),
|
||||
):
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert connections["default"].get_autocommit()
|
||||
assert connections["admin"].get_autocommit()
|
||||
assert not User.objects.using("default").filter(email=email).exists()
|
||||
assert not User.objects.using("admin").filter(email=email).exists()
|
||||
assert (
|
||||
not SocialAccount.objects.using("admin")
|
||||
.filter(
|
||||
provider="google",
|
||||
uid="frank-multidb-rollback-google-account",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert not EmailAddress.objects.using("admin").filter(email=email).exists()
|
||||
assert Tenant.objects.using("admin").count() == tenants_before
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_commits_complete_signup_across_production_aliases(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-multidb-success@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-multidb-success-google-account",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
self._production_router(),
|
||||
):
|
||||
user = adapter.save_user(request, sociallogin)
|
||||
|
||||
user = User.objects.using("admin").get(id=user.id)
|
||||
assert user.email == email
|
||||
assert (
|
||||
SocialAccount.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
provider="google",
|
||||
uid="frank-multidb-success-google-account",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
EmailAddress.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
email=email,
|
||||
verified=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
Membership.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
role=Membership.RoleChoices.OWNER,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert get_write_db_alias() is None
|
||||
@@ -154,6 +154,88 @@ def test_execute_query_serializes_graph(
|
||||
assert result["relationships"][0]["label"] == "OWNS"
|
||||
|
||||
|
||||
def test_execute_query_injects_provider_label_when_migrated(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
):
|
||||
# On migrated graphs the predefined cypher must be scoped with the
|
||||
# provider label so the planner seeds from the label index instead of a
|
||||
# global label scan (the Neptune cartesian/timeout fix).
|
||||
definition = attack_paths_query_definition_factory(
|
||||
id="aws-iam",
|
||||
name="IAM",
|
||||
short_description="Short desc",
|
||||
description="",
|
||||
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
|
||||
parameters=[],
|
||||
)
|
||||
provider_id = "test-provider-123"
|
||||
plabel = get_provider_label(provider_id)
|
||||
parameters = {"provider_uid": "123"}
|
||||
|
||||
graph_result = MagicMock()
|
||||
graph_result.nodes = []
|
||||
graph_result.relationships = []
|
||||
sink_backend_stub.execute_read_query.return_value = graph_result
|
||||
|
||||
# Injection is gated on `is_migrated`, not the sink (it is a pure string
|
||||
# transform), so `neo4j` exercises the same code path as Neptune here.
|
||||
views_helpers.execute_query(
|
||||
"db-tenant-test",
|
||||
definition,
|
||||
parameters,
|
||||
provider_id=provider_id,
|
||||
scan=MagicMock(is_migrated=True, sink_backend="neo4j"),
|
||||
)
|
||||
|
||||
executed_cypher = sink_backend_stub.execute_read_query.call_args[0][1]
|
||||
assert executed_cypher != definition.cypher
|
||||
# Both node patterns are scoped - not just one. Asserting the exact rewrite
|
||||
# (rather than `f":{plabel}" in executed_cypher`, which a partial injection
|
||||
# would still satisfy) proves every node got the label and that injection
|
||||
# inserted labels and nothing else.
|
||||
assert executed_cypher == (
|
||||
f"MATCH (aws:AWSAccount:{plabel})--(target_role:AWSRole:{plabel}) "
|
||||
"RETURN target_role"
|
||||
)
|
||||
# Parameters are passed through untouched.
|
||||
assert sink_backend_stub.execute_read_query.call_args[0][2] == parameters
|
||||
|
||||
|
||||
def test_execute_query_does_not_inject_label_when_deprecated(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
):
|
||||
# The pre-cutover legacy catalog runs on the old sink and is removed after
|
||||
# the Neptune cutover, so it must run verbatim (no injection).
|
||||
definition = attack_paths_query_definition_factory(
|
||||
id="aws-iam",
|
||||
name="IAM",
|
||||
short_description="Short desc",
|
||||
description="",
|
||||
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
|
||||
parameters=[],
|
||||
)
|
||||
parameters = {"provider_uid": "123"}
|
||||
|
||||
graph_result = MagicMock()
|
||||
graph_result.nodes = []
|
||||
graph_result.relationships = []
|
||||
sink_backend_stub.execute_read_query.return_value = graph_result
|
||||
|
||||
views_helpers.execute_query(
|
||||
"db-tenant-test",
|
||||
definition,
|
||||
parameters,
|
||||
provider_id="test-provider-123",
|
||||
scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
|
||||
)
|
||||
|
||||
sink_backend_stub.execute_read_query.assert_called_once_with(
|
||||
"db-tenant-test", definition.cypher, parameters
|
||||
)
|
||||
|
||||
|
||||
def test_execute_query_wraps_graph_errors(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
@@ -239,7 +321,7 @@ def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_class
|
||||
relationships=[rel_keep, rel_drop_orphaned],
|
||||
)
|
||||
|
||||
result = views_helpers._serialize_graph(graph, provider_id)
|
||||
result = views_helpers._serialize_graph(graph, provider_id, _all_persisted())
|
||||
|
||||
assert len(result["nodes"]) == 1
|
||||
assert result["nodes"][0]["id"] == "n1"
|
||||
@@ -247,6 +329,71 @@ def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_class
|
||||
assert result["relationships"][0]["id"] == "r1"
|
||||
|
||||
|
||||
def _all_persisted():
|
||||
return SimpleNamespace(is_persisted_element_id=lambda element_id: True)
|
||||
|
||||
|
||||
def test_serialize_graph_drops_virtual_entities(attack_paths_graph_stub_classes):
|
||||
provider_id = "provider-keep"
|
||||
plabel = get_provider_label(provider_id)
|
||||
stored_ids = {"n1", "n2", "r1"}
|
||||
backend = SimpleNamespace(
|
||||
is_persisted_element_id=lambda element_id: element_id in stored_ids
|
||||
)
|
||||
|
||||
node_1 = attack_paths_graph_stub_classes.Node("n1", ["AWSAccount", plabel], {})
|
||||
node_2 = attack_paths_graph_stub_classes.Node("n2", ["AWSRole", plabel], {})
|
||||
# apoc.create.vNode can carry the provider label and any payload
|
||||
node_virtual = attack_paths_graph_stub_classes.Node(
|
||||
"-1", ["AWSAccount", plabel], {"body": "fetched"}
|
||||
)
|
||||
|
||||
rel_stored = attack_paths_graph_stub_classes.Relationship(
|
||||
"r1", "OWNS", node_1, node_2, {}
|
||||
)
|
||||
# apoc.create.vRelationship between two stored nodes passes the endpoint check
|
||||
rel_virtual = attack_paths_graph_stub_classes.Relationship(
|
||||
"45cc10ea-9618-47fb-abaa-8e82a58d6b40", "V", node_1, node_2, {"body": "fetched"}
|
||||
)
|
||||
rel_to_virtual = attack_paths_graph_stub_classes.Relationship(
|
||||
"r2", "OWNS", node_1, node_virtual, {}
|
||||
)
|
||||
|
||||
graph = SimpleNamespace(
|
||||
nodes=[node_1, node_2, node_virtual],
|
||||
relationships=[rel_stored, rel_virtual, rel_to_virtual],
|
||||
)
|
||||
|
||||
result = views_helpers._serialize_graph(graph, provider_id, backend)
|
||||
|
||||
assert [node["id"] for node in result["nodes"]] == ["n1", "n2"]
|
||||
assert [rel["id"] for rel in result["relationships"]] == ["r1"]
|
||||
assert "fetched" not in str(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"element_id, persisted",
|
||||
[
|
||||
("4:9f1d9d76-bb08-478c-b25d-7e577f967732:0", True),
|
||||
("5:9f1d9d76-bb08-478c-b25d-7e577f967732:12", True),
|
||||
("-2", False),
|
||||
("45cc10ea-9618-47fb-abaa-8e82a58d6b40", False),
|
||||
("", False),
|
||||
],
|
||||
ids=[
|
||||
"stored_node",
|
||||
"stored_relationship",
|
||||
"virtual_node",
|
||||
"virtual_relationship",
|
||||
"empty",
|
||||
],
|
||||
)
|
||||
def test_neo4j_sink_is_persisted_element_id(element_id, persisted):
|
||||
from api.attack_paths.sink.neo4j import Neo4jSink
|
||||
|
||||
assert Neo4jSink().is_persisted_element_id(element_id) is persisted
|
||||
|
||||
|
||||
# -- serialize_graph_as_text -------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Loaded 100 of 3458 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user