├── kql ├── call.kql ├── querysignins.kql ├── appProxy.kql ├── auditSPNabuse.kql ├── la.kql ├── samlSettings.kql ├── laWaudit.kql ├── apiSort.kql ├── withRequiredAccess.kql ├── debug.kql ├── queryAppprx.kql └── query.kql ├── .gitattributes ├── Pictures ├── Login-3.JPG ├── Results-1.jpg ├── Results-2.jpg └── Results-2-1.jpg ├── preq.js ├── .gitignore ├── package.json ├── src ├── dnslook.js ├── axioshelpers.js ├── pluginRunner.js ├── batcher2.js ├── genericThrottle.js ├── getToken.js ├── src.js └── graphf.js ├── nodeparse.js ├── SchemaStorage.js ├── remoteInit.sh ├── precheck.js ├── retention.json ├── LICENSE ├── mainExtended.js ├── main.js ├── schemaForExternalDataExtended.js ├── mainSignIns.js ├── remote.sh ├── schemaSignins.js ├── schemaForAPIdriven.js ├── schemaForSaml.js ├── schemaForAppProxyApps.js ├── schemaDebug.js ├── schemaForExternalDataLAsignins.js ├── schemaForMaliciousMultiTenant.js ├── schemaForExternalDataLAsignisAndAudit.js ├── nodeparsedates2.js ├── schemaForExternalData.js ├── admins.js ├── nodeparse2.js ├── material └── doNotRemove.json ├── Client2.js └── readme.md /kql/call.kql: -------------------------------------------------------------------------------- 1 | //this one invokes the basequery 2 | final -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Convert to LF line endings on checkout. 2 | *.sh text eol=lf -------------------------------------------------------------------------------- /Pictures/Login-3.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsa2/CloudShellAadApps/HEAD/Pictures/Login-3.JPG -------------------------------------------------------------------------------- /Pictures/Results-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsa2/CloudShellAadApps/HEAD/Pictures/Results-1.jpg -------------------------------------------------------------------------------- /Pictures/Results-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsa2/CloudShellAadApps/HEAD/Pictures/Results-2.jpg -------------------------------------------------------------------------------- /Pictures/Results-2-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsa2/CloudShellAadApps/HEAD/Pictures/Results-2-1.jpg -------------------------------------------------------------------------------- /preq.js: -------------------------------------------------------------------------------- 1 | const getToken = require("./src/getToken"); 2 | 3 | getToken().then((data) => { 4 | console.log(data) 5 | }).catch((error) => { 6 | console.log(error) 7 | }) -------------------------------------------------------------------------------- /kql/querysignins.kql: -------------------------------------------------------------------------------- 1 | | join kind=fullouter (signins 2 | | summarize count() by appDisplayName| project appDisplayName, signIns=count_) on $left.clientDisplay == $right.appDisplayName | where isnotempty( clientDisplay) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | token.JSON 3 | *.json 4 | runtime.kql 5 | tid.txt 6 | # exclude from JSON flag 7 | !package.json 8 | !package-lock.json 9 | !doNotRemove.json 10 | !retention.json 11 | 12 | -------------------------------------------------------------------------------- /kql/appProxy.kql: -------------------------------------------------------------------------------- 1 | //// /////// 2 | servicePrincipalsUP 3 | | where isnotempty(onPremisesPublishing) 4 | | project appDisplayName, appId, onPremisesPublishing 5 | | evaluate bag_unpack(onPremisesPublishing) 6 | | extend isHealthy = case(isHttpOnlyCookieEnabled == false, false, isSecureCookieEnabled == false, true, externalAuthenticationType has "aadPreAuthentication", true, false) 7 | ///// -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamicfunc", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "func start", 7 | "test": "echo \"No tests yet...\"" 8 | }, 9 | "dependencies": { 10 | "axios": "^1.3.2", 11 | "azure-storage": "^2.10.7", 12 | "jsonwebtoken": "^9", 13 | "yargs": "^17.6.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /kql/auditSPNabuse.kql: -------------------------------------------------------------------------------- 1 | // 2 | let mass = AADServicePrincipalSignInLogs 3 | | where TimeGenerated > now() -30d 4 | | extend displayName = ServicePrincipalName; 5 | let sd= mass 6 | | summarize max(TimeGenerated) by displayName 7 | | join kind=inner (mass | summarize count() by displayName) on displayName 8 | | project-away displayName1; 9 | sd 10 | | join kind=inner final on $left.displayName == $right.clientDisplay 11 | | where AppType == "MultiTenant" and isempty( set_RolescombinedAssignment) -------------------------------------------------------------------------------- /src/dnslook.js: -------------------------------------------------------------------------------- 1 | 2 | const { resolveAny } = require('dns').promises; 3 | 4 | /* main() 5 | async function main () { 6 | var r = await getWebSitesName('a2z.dewi.red') 7 | console.log(r) 8 | } 9 | */ 10 | 11 | async function getWebSitesName (object) { 12 | var {fqdn,id} = object 13 | var address = fqdn.split('//')[1].split('/')[0] 14 | try { 15 | await resolveAny(address) 16 | return {fqdn,id,failed:false} 17 | } catch (error) { 18 | return {fqdn,id, failed:true} 19 | } 20 | 21 | 22 | } 23 | 24 | module.exports={getWebSitesName} -------------------------------------------------------------------------------- /nodeparse.js: -------------------------------------------------------------------------------- 1 | var appr = require('./roles.json') 2 | var spns = require('./servicePrincipals.json') 3 | 4 | 5 | appr.map((item) =>{ 6 | 7 | var spn = spns.find((spn) => spn.id == item.resourceId ) 8 | var role= spn?.appRoles.find((role) => role.id == item.appRoleId) 9 | 10 | spn 11 | role 12 | 13 | item.appOwnerOrganizationId = spn.appOwnerOrganizationId 14 | item.assignedRole = role || 'no roles' 15 | 16 | }) 17 | 18 | 19 | console.log(appr) 20 | 21 | require('fs').writeFileSync('./material/rolesUP.json',JSON.stringify(appr)) -------------------------------------------------------------------------------- /SchemaStorage.js: -------------------------------------------------------------------------------- 1 | const {createContainer, upload, getSasUrl } = require('./src/src') 2 | 3 | 4 | async function createStorage (container,file, filePath) { 5 | 6 | await createContainer(container).catch(error => { 7 | console.log( error ) 8 | throw new Error('Unable to work with storage',error) 9 | }) 10 | await upload(container, file, filePath) 11 | //120 is 120 hours, change the value to desired amount 12 | var url = getSasUrl(container, file, 10) 13 | 14 | return url 15 | 16 | } 17 | 18 | 19 | module.exports={createStorage} 20 | -------------------------------------------------------------------------------- /remoteInit.sh: -------------------------------------------------------------------------------- 1 | 2 | 3 | RANDOM=$RANDOM 4 | locations=consentAnalytics-$RANDOM 5 | 6 | git clone 'https://github.com/jsa2/CloudShellAadApps.git' $locations 7 | 8 | cd $locations 9 | 10 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash 11 | 12 | export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" 13 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm 14 | 15 | nvm install 14 16 | 17 | nvm use 14 18 | 19 | nvm alias default 14 20 | 21 | npm install 22 | 23 | echo "run cd $locations to continue" 24 | -------------------------------------------------------------------------------- /kql/la.kql: -------------------------------------------------------------------------------- 1 | //With SignInLogs (requires that signInLogs are stored in the same space) 2 | let mass = materialize (union AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs, AADManagedIdentitySignInLogs, SigninLogs 3 | | where TimeGenerated > now() -30d 4 | | extend displayName = iff (isempty( AppDisplayName), ServicePrincipalName, AppDisplayName)); 5 | let sd= mass 6 | | summarize max(TimeGenerated) by displayName 7 | | join kind=inner (mass | summarize count() by displayName) on displayName 8 | | project-away displayName1; 9 | sd 10 | | join kind=inner final on $left.displayName == $right.clientDisplay -------------------------------------------------------------------------------- /kql/samlSettings.kql: -------------------------------------------------------------------------------- 1 | //// /////// 2 | servicePrincipalsUP 3 | | project samlSettings, displayName, notificationEmailAddresses 4 | | where isnotempty( samlSettings) 5 | |mv-expand samlSettings.keyCredentials 6 | | mv-apply samlSettings_keyCredentials.endDateTime on ( 7 | extend endDate =(datetime_diff('day',todatetime(samlSettings_keyCredentials.endDateTime),now())) 8 | | extend HasExpiringPassword = endDate < 500 9 | | extend info = samlSettings_keyCredentials.endDateTime 10 | ) 11 | | summarize make_set(tostring(samlSettings_keyCredentials.customKeyIdentifier)) by displayName, endDateInDays = endDate, tostring(notificationEmailAddresses) 12 | //// /////// -------------------------------------------------------------------------------- /src/axioshelpers.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const qs = require('querystring') 3 | 4 | async function axiosClient (options, urlencoded, debug) { 5 | 6 | if (urlencoded == true) { 7 | options.data = qs.stringify(options.data) 8 | } 9 | if (debug) { 10 | console.log(options) 11 | } 12 | 13 | var data = await axios(options).catch((error) => { 14 | /* console.log(error?.Error) */ 15 | return Promise.reject(error?.response?.data || error?.response?.status || error?.message) 16 | 17 | }) 18 | 19 | return data?.data || data.status 20 | 21 | } 22 | 23 | module.exports = {axiosClient} -------------------------------------------------------------------------------- /precheck.js: -------------------------------------------------------------------------------- 1 | const {createContainer} = require('./src/src') 2 | const getToken = require('./src/getToken') 3 | 4 | async function preCheck () { 5 | 6 | console.log('testing access tokens') 7 | 8 | var t = await getToken().catch((error) => { 9 | throw new Error('Unable to work with Access Tokens',error) 10 | }) 11 | 12 | console.log('Az access ok',t.substring(0,15)) 13 | 14 | console.log('testing storage') 15 | var s =await createContainer('sdasdasdsarewrewrewre').catch(error => { 16 | console.log( error ) 17 | return Promise.reject(`Unable to work with storage ${error}` ) 18 | }) 19 | 20 | console.log('storage access ok',s) 21 | 22 | } 23 | 24 | 25 | module.exports={preCheck} -------------------------------------------------------------------------------- /src/pluginRunner.js: -------------------------------------------------------------------------------- 1 | const {exec} =require('child_process') 2 | 3 | const wexc = require('util').promisify(exec) 4 | var path = require('path') 5 | const bfr = {maxBuffer: 1024 * 1024} 6 | 7 | // Control with NodeSchema 8 | 9 | async function runner (script) { 10 | 11 | try { 12 | var {stdout,stderr} = await wexc(script, bfr) 13 | results = JSON.parse(stdout) 14 | //console.log(results) 15 | return results 16 | } 17 | catch(error) { 18 | console.log(error) 19 | return `Failed to process ${script}, due to ${JSON.stringify(error)}` 20 | } 21 | 22 | 23 | 24 | } 25 | 26 | module.exports={runner} 27 | 28 | 29 | -------------------------------------------------------------------------------- /retention.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": [ 3 | { 4 | "definition": { 5 | "actions": { 6 | "baseBlob": { 7 | "delete": { 8 | "daysAfterLastAccessTimeGreaterThan": null, 9 | "daysAfterModificationGreaterThan": 0.0 10 | }, 11 | "enableAutoTierToHotFromCool": null, 12 | "tierToArchive": null, 13 | "tierToCool": null 14 | }, 15 | "snapshot": null, 16 | "version": null 17 | }, 18 | "filters": { 19 | "blobIndexMatch": null, 20 | "blobTypes": [ 21 | "blockBlob" 22 | ], 23 | "prefixMatch": null 24 | } 25 | }, 26 | "enabled": true, 27 | "name": "allDelete", 28 | "type": "Lifecycle" 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /kql/laWaudit.kql: -------------------------------------------------------------------------------- 1 | //With SignInLogs (requires that signInLogs are stored in the same space) 2 | let mass = materialize (union AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs, AADManagedIdentitySignInLogs, SigninLogs 3 | | where TimeGenerated > now() -30d 4 | | extend displayName = iff (isempty( AppDisplayName), ServicePrincipalName, AppDisplayName)); 5 | let sd= mass 6 | | summarize max(TimeGenerated) by displayName 7 | | join kind=inner (mass | summarize count() by displayName) on displayName 8 | | project-away displayName1; 9 | sd 10 | | join kind=inner final on $left.displayName == $right.clientDisplay 11 | | join kind=fullouter (AuditLogs 12 | | where TimeGenerated > now()-1d 13 | | project Identity, OperationName 14 | | where isnotempty( Identity) 15 | | summarize make_set(OperationName) by Identity) on $left.displayName == $right.Identity 16 | | where isnotempty( clientDisplay) 17 | | extend signIns = count_ 18 | | extend auditLogs = set_OperationName 19 | | project-away Identity, count_ -------------------------------------------------------------------------------- /src/batcher2.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var waitT = require('util').promisify(setTimeout) 4 | 5 | async function batchThrottledSimple (burstCount, arrayOfObjects) { 6 | 7 | var promArra = [] 8 | var returnObject = [] 9 | let i = 0 10 | 11 | for await ({runContext} of arrayOfObjects) { 12 | i++ 13 | // console.log(i) 14 | 15 | var {fn,opts} = runContext 16 | 17 | if (i % burstCount == 0) { 18 | await waitT(1000) 19 | } 20 | 21 | promArra.push( 22 | fn(opts).catch((error) => { 23 | console.log('no match in graph', opts) 24 | //returnObject.push({error:resourceId}) 25 | }).then((data) => { 26 | if (data) { 27 | returnObject.push(data) 28 | } 29 | 30 | }) 31 | ) 32 | 33 | } 34 | 35 | await Promise.all(promArra) 36 | return returnObject 37 | 38 | } 39 | 40 | module.exports={batchThrottledSimple} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joosua Santasalo / Sami Lamppu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mainExtended.js: -------------------------------------------------------------------------------- 1 | const { decode } = require("jsonwebtoken"); 2 | const { main } = require("./Client2"); 3 | const getToken = require("./src/getToken"); 4 | const { runner } = require("./src/pluginRunner"); 5 | const fs = require('fs'); 6 | const { exec } = require("child_process"); 7 | const wexc = require('util').promisify(exec) 8 | 9 | run() 10 | 11 | async function run () { 12 | 13 | var token = await getToken() 14 | 15 | var tEnc = decode(token) 16 | fs.writeFileSync('kql/tid.txt',tEnc.tid) 17 | 18 | await main({ 19 | access_token:token, 20 | resource:"https://graph.microsoft.com" 21 | }) 22 | 23 | try { 24 | await wexc('node nodeparse.js').catch((error) => { 25 | console.log(error) 26 | }) 27 | await wexc('node nodeparse2.js') 28 | //await wexc('node dynamicSend.js') 29 | console.log('creating query') 30 | await wexc('node schemaForExternalDataExtended.js') 31 | console.log('open kql/runtime.kql') 32 | } catch (error) { 33 | console.log('faield', error) 34 | } 35 | 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/genericThrottle.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = async function Throttld(burstCount, data, invokeFn, wait) { 3 | var count = 0 4 | var fullar = [] 5 | var burstArray = [] 6 | var residue = data.length % burstCount 7 | 8 | console.log('residue is', residue) 9 | 10 | for await (item of data) { 11 | 12 | count++ 13 | 14 | burstArray.push(item) 15 | 16 | if (count % burstCount == 0) { 17 | console.log('chunk sent') 18 | 19 | fullar.push(burstArray) 20 | if (invokeFn && wait) { 21 | await invokeFn() 22 | } 23 | if (invokeFn && !wait) { 24 | invokeFn() 25 | } 26 | var burstArray = [] 27 | } 28 | } 29 | var resid = data.splice((data.length - residue), data.length) 30 | if (resid.length > 0) { 31 | console.log(resid < 0) 32 | console.log('residss') 33 | fullar.push(resid) 34 | if (invokeFn && wait) { 35 | await invokeFn() 36 | } 37 | if (invokeFn && !wait) { 38 | invokeFn() 39 | } 40 | } 41 | console.log(fullar) 42 | } 43 | 44 | -------------------------------------------------------------------------------- /src/getToken.js: -------------------------------------------------------------------------------- 1 | 2 | var fs = require('fs') 3 | const { decode } = require("jsonwebtoken") 4 | var path = require('path') 5 | const { runner } = require('./pluginRunner') 6 | 7 | 8 | async function getToken() { 9 | 10 | try { 11 | 12 | const token = require('sessionToken.json') 13 | const decoded = decode(token) 14 | const now = Date.now().valueOf() / 1000 15 | //https://stackoverflow.com/a/55706292 (not using full verification, as the token is not meant to be validated in this tool, but in Azure API) 16 | if (typeof decoded.exp !== 'undefined' && decoded.exp < now) { 17 | throw new Error(`token expired: ${JSON.stringify(decoded)}`) 18 | } 19 | if (typeof decoded.nbf !== 'undefined' && decoded.nbf > now) { 20 | throw new Error(`token expired: ${JSON.stringify(decoded)}`) 21 | } 22 | 23 | return token 24 | 25 | } catch (error) { 26 | var token = await runner('az account get-access-token --resource=https://graph.microsoft.com --query accessToken --output json') 27 | fs.writeFileSync( 'sessionToken.json',JSON.stringify(token)) 28 | return token || error 29 | 30 | } 31 | 32 | 33 | } 34 | 35 | module.exports=getToken -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const { decode } = require("jsonwebtoken"); 2 | const { main } = require("./Client2"); 3 | const getToken = require("./src/getToken"); 4 | const fs = require('fs'); 5 | const { exec } = require("child_process"); 6 | const { preCheck } = require("./precheck"); 7 | const { admins } = require("./admins"); 8 | const wexc = require('util').promisify(exec) 9 | 10 | run().catch((error) => { 11 | console.log(error) 12 | }) 13 | 14 | async function run () { 15 | 16 | await preCheck().catch((error) => { 17 | return Promise.reject(`Unable to work due prerequisites failing: ${error}` ) 18 | }) 19 | 20 | var token = await getToken() 21 | 22 | var tEnc = decode(token) 23 | fs.writeFileSync('kql/tid.txt',tEnc.tid) 24 | 25 | await main({ 26 | access_token:token, 27 | resource:"https://graph.microsoft.com" 28 | }) 29 | 30 | await admins() 31 | 32 | try { 33 | await wexc('node nodeparse.js').catch((error) => { 34 | console.log(error, 'aborting') 35 | return; 36 | }) 37 | await wexc('node nodeparse2.js') 38 | //await wexc('node dynamicSend.js') 39 | console.log('creating query') 40 | await wexc('node schemaForExternalData.js') 41 | console.log('open kql/runtime.kql') 42 | } catch (error) { 43 | console.log('faield', error) 44 | } 45 | 46 | 47 | } 48 | -------------------------------------------------------------------------------- /schemaForExternalDataExtended.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | delete content[0]['@odata.id'] 20 | var k = Object.keys(content[0]) 21 | k.forEach((key, index) => { 22 | 23 | var type = typeof(content[0][key]) 24 | if (type == "object") { 25 | schema += `${key}: dynamic` 26 | } else { 27 | schema += `${key}: string` 28 | } 29 | 30 | if (index !== (k.length - 1)) { 31 | schema += ", " 32 | } 33 | }) 34 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 35 | schema += `)[@"${url}"] with (format="multijson"));` 36 | schema += '\n //' 37 | fullSchema+=schema 38 | 39 | } 40 | 41 | var baseq = fs.readFileSync('kql/withRequiredAccess.kql').toString() 42 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq) 43 | 44 | } 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /mainSignIns.js: -------------------------------------------------------------------------------- 1 | const { decode } = require("jsonwebtoken"); 2 | const { main } = require("./Client2"); 3 | const getToken = require("./src/getToken"); 4 | const { runner } = require("./src/pluginRunner"); 5 | const fs = require('fs'); 6 | const { exec } = require("child_process"); 7 | const { getSignIns } = require("./signins"); 8 | const { admins } = require("./admins"); 9 | const wexc = require('util').promisify(exec) 10 | 11 | const waits = require('util').promisify(setTimeout) 12 | 13 | run() 14 | 15 | async function run () { 16 | 17 | var token = await getToken() 18 | 19 | var tEnc = decode(token) 20 | fs.writeFileSync('kql/tid.txt',tEnc.tid) 21 | 22 | await main({ 23 | access_token:token, 24 | resource:"https://graph.microsoft.com" 25 | }) 26 | 27 | await admins() 28 | 29 | try { 30 | await wexc('node nodeparse.js').catch((error) => { 31 | console.log(error) 32 | }) 33 | await wexc('node nodeparse2.js') 34 | //await wexc('node dynamicSend.js') 35 | 36 | console.log('getting signin logs, this might take a while') 37 | await waits(5000) 38 | 39 | 40 | await getSignIns(24) 41 | 42 | console.log('creating query') 43 | await wexc('node schemaSignins.js') 44 | console.log('open kql/runtime.kql') 45 | } catch (error) { 46 | console.log('faield', error) 47 | } 48 | 49 | 50 | } 51 | -------------------------------------------------------------------------------- /remote.sh: -------------------------------------------------------------------------------- 1 | # 2 | # curl -o- https://raw.githubusercontent.com/jsa2/CloudShellAadApps/public/remote.sh | bash 3 | 4 | git clone 'https://github.com/jsa2/CloudShellAadApps.git' 5 | 6 | cd CloudShellAadApps 7 | 8 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash 9 | 10 | export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" 11 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm 12 | 13 | nvm install 14 14 | 15 | nvm use 14 16 | 17 | nvm alias default 14 18 | 19 | npm install 20 | 21 | rnd=$RANDOM 22 | rg=queryStorage-$rnd 23 | location=westeurope 24 | # You can ignore the warning "command substitution: ignored null byte in input" 25 | storageAcc=storage$(head /dev/urandom | tr -dc a-z | head -c10) 26 | 27 | echo $storageAcc 28 | # Create Resource Group 29 | az group create -n $rg \ 30 | -l $location \ 31 | --tags="svc=scan" 32 | 33 | 34 | # Create storageAcc Account 35 | az storage account create -n $storageAcc -g $rg --kind storageV2 -l $location -t Account --sku Standard_LRS 36 | 37 | az storage account show-connection-string -g $rg -n $storageAcc -o json > src/config.json 38 | 39 | az storage account management-policy create --account-name $storageAcc -g $rg --policy @retention.json 40 | 41 | node main.js 42 | 43 | echo "navigate to kql/runtime.kql if code does not open up" 44 | 45 | code kql/runtime.kql 46 | 47 | echo "To later delete the deployment type:" 48 | 49 | 50 | echo "az group delete -n $rg " 51 | 52 | echo "az group delete -n $rg " > deleteDepl.sh 53 | -------------------------------------------------------------------------------- /schemaSignins.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | delete content[0]['@odata.id'] 20 | var k = Object.keys(content[0]) 21 | k.forEach((key, index) => { 22 | 23 | var type = typeof(content[0][key]) 24 | if (type == "object") { 25 | schema += `${key}: dynamic` 26 | } else { 27 | schema += `${key}: string` 28 | } 29 | 30 | if (index !== (k.length - 1)) { 31 | schema += ", " 32 | } 33 | }) 34 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 35 | schema += `)[@"${url}"] with (format="multijson"));` 36 | schema += '\n //' 37 | fullSchema+=schema 38 | 39 | } 40 | 41 | var signIns = fs.readFileSync('kql/querysignins.kql').toString() 42 | var baseq = fs.readFileSync('kql/query.kql').toString() 43 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq+signIns) 44 | 45 | } 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /schemaForAPIdriven.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | 20 | try {delete content[0]['@odata.id']} catch (error) { 21 | console.log('different schema') 22 | } 23 | 24 | var k = Object.keys(content[0]) 25 | k.forEach((key, index) => { 26 | 27 | var type = typeof(content[0][key]) 28 | if (type == "object") { 29 | schema += `${key}: dynamic` 30 | } else { 31 | schema += `${key}: string` 32 | } 33 | 34 | if (index !== (k.length - 1)) { 35 | schema += ", " 36 | } 37 | }) 38 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 39 | schema += `)[@"${url}"] with (format="multijson"));` 40 | schema += '\n //' 41 | fullSchema+=schema 42 | 43 | } 44 | 45 | var baseq = fs.readFileSync('kql/apiSort.kql').toString() 46 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq) 47 | 48 | } 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /schemaForSaml.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)).filter(f => f.match('servicePrincipals')) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | 20 | try {delete content[0]['@odata.id']} catch (error) { 21 | console.log('different schema') 22 | } 23 | 24 | var k = Object.keys(content[0]) 25 | k.forEach((key, index) => { 26 | 27 | var type = typeof(content[0][key]) 28 | if (type == "object") { 29 | schema += `${key}: dynamic` 30 | } else { 31 | schema += `${key}: string` 32 | } 33 | 34 | if (index !== (k.length - 1)) { 35 | schema += ", " 36 | } 37 | }) 38 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 39 | schema += `)[@"${url}"] with (format="multijson"));` 40 | schema += '\n //' 41 | fullSchema+=schema 42 | 43 | } 44 | var baseq = fs.readFileSync('kql/samlSettings.kql').toString() 45 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq) 46 | 47 | } 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /schemaForAppProxyApps.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)).filter(f => f.match('servicePrincipals')) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | 20 | try {delete content[0]['@odata.id']} catch (error) { 21 | console.log('different schema') 22 | } 23 | 24 | var k = Object.keys(content[0]) 25 | k.forEach((key, index) => { 26 | 27 | var type = typeof(content[0][key]) 28 | if (type == "object") { 29 | schema += `${key}: dynamic` 30 | } else { 31 | schema += `${key}: string` 32 | } 33 | 34 | if (index !== (k.length - 1)) { 35 | schema += ", " 36 | } 37 | }) 38 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 39 | schema += `)[@"${url}"] with (format="multijson"));` 40 | schema += '\n //' 41 | fullSchema+=schema 42 | 43 | } 44 | var baseq = fs.readFileSync('kql/appProxy.kql').toString() 45 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq) 46 | 47 | } 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /schemaDebug.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | 20 | try {delete content[0]['@odata.id']} catch (error) { 21 | console.log('different schema') 22 | } 23 | 24 | var k = Object.keys(content[0]) 25 | k.forEach((key, index) => { 26 | 27 | var type = typeof(content[0][key]) 28 | if (type == "object") { 29 | schema += `${key}: dynamic` 30 | } else { 31 | schema += `${key}: string` 32 | } 33 | 34 | if (index !== (k.length - 1)) { 35 | schema += ", " 36 | } 37 | }) 38 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 39 | schema += `)[@"${url}"] with (format="multijson"));` 40 | schema += '\n //' 41 | fullSchema+=schema 42 | 43 | } 44 | let callIt = fs.readFileSync('kql/call.kql').toString() 45 | var baseq = fs.readFileSync('kql/debug.kql').toString() 46 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq+callIt) 47 | 48 | } 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /schemaForExternalDataLAsignins.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | 20 | try {delete content[0]['@odata.id']} catch (error) { 21 | console.log('different schema') 22 | } 23 | 24 | var k = Object.keys(content[0]) 25 | k.forEach((key, index) => { 26 | 27 | var type = typeof(content[0][key]) 28 | if (type == "object") { 29 | schema += `${key}: dynamic` 30 | } else { 31 | schema += `${key}: string` 32 | } 33 | 34 | if (index !== (k.length - 1)) { 35 | schema += ", " 36 | } 37 | }) 38 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 39 | schema += `)[@"${url}"] with (format="multijson"));` 40 | schema += '\n //' 41 | fullSchema+=schema 42 | 43 | } 44 | let signin = fs.readFileSync('kql/la.kql').toString() 45 | var baseq = fs.readFileSync('kql/query.kql').toString() 46 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq+signin) 47 | 48 | } 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /schemaForMaliciousMultiTenant.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | 20 | try {delete content[0]['@odata.id']} catch (error) { 21 | console.log('different schema') 22 | } 23 | 24 | var k = Object.keys(content[0]) 25 | k.forEach((key, index) => { 26 | 27 | var type = typeof(content[0][key]) 28 | if (type == "object") { 29 | schema += `${key}: dynamic` 30 | } else { 31 | schema += `${key}: string` 32 | } 33 | 34 | if (index !== (k.length - 1)) { 35 | schema += ", " 36 | } 37 | }) 38 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 39 | schema += `)[@"${url}"] with (format="multijson"));` 40 | schema += '\n //' 41 | fullSchema+=schema 42 | 43 | } 44 | let signin = fs.readFileSync('kql/auditSPNabuse.kql').toString() 45 | var baseq = fs.readFileSync('kql/query.kql').toString() 46 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq+signin) 47 | 48 | } 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /schemaForExternalDataLAsignisAndAudit.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | 20 | try {delete content[0]['@odata.id']} catch (error) { 21 | console.log('different schema') 22 | } 23 | 24 | var k = Object.keys(content[0]) 25 | k.forEach((key, index) => { 26 | 27 | var type = typeof(content[0][key]) 28 | if (type == "object") { 29 | schema += `${key}: dynamic` 30 | } else { 31 | schema += `${key}: string` 32 | } 33 | 34 | if (index !== (k.length - 1)) { 35 | schema += ", " 36 | } 37 | }) 38 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 39 | schema += `)[@"${url}"] with (format="multijson"));` 40 | schema += '\n //' 41 | fullSchema+=schema 42 | 43 | } 44 | let signin = fs.readFileSync('kql/laWaudit.kql').toString() 45 | var baseq = fs.readFileSync('kql/query.kql').toString() 46 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq+signin) 47 | 48 | } 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /nodeparsedates2.js: -------------------------------------------------------------------------------- 1 | var users = require('./users.json') 2 | var oauth2Grants = require('./oauth2PermissionGrants.json') 3 | var spns = require('./servicePrincipals.json') 4 | var applications = require('./applications-save.json') 5 | 6 | 7 | oauth2Grants.map((item) => { 8 | 9 | var match = users.find((user) => user.id == item.principalId) 10 | 11 | item.userPrincipalName = match?.userPrincipalName || null 12 | return item 13 | } ) 14 | 15 | oauth2Grants.map((item) => { 16 | 17 | var match = spns.find((spn) => spn.id == item.resourceId) 18 | 19 | item.resourceDisplayName = match.displayName || null 20 | 21 | }) 22 | 23 | spns.map( (item) =>{ 24 | 25 | var pw = applications.find((spn) => spn.appId == item.appId ) 26 | 27 | if (pw?.passwordCredentials.length > 0) { 28 | item.passwordCredentialsExpiring = pw?.passwordCredentials.map((cred) =>{ 29 | var remainingDays = getNumberOfDays(new Date(), new Date(cred.endDateTime)) 30 | return { 31 | remainingDays, 32 | displayName:cred.displayName 33 | } 34 | 35 | }) 36 | } 37 | 38 | item.passwordCredentialsExpiring =item?.passwordCredentialsExpiring || [] 39 | 40 | item.nextExpiration = item.passwordCredentialsExpiring.sort((a,b) =>{ 41 | console.log(a) 42 | }) 43 | 44 | item.ApplicationHasPassword = pw?.passwordCredentials || item.passwordCredentials 45 | delete item.appRoles; delete item.oauth2PermissionScopes 46 | 47 | }) 48 | 49 | // console.log(oauth2Grants) 50 | 51 | require('fs').writeFileSync('./material/servicePrincipalsUP.json',JSON.stringify(spns)) 52 | 53 | require('fs').writeFileSync('./material/oauth2PermissionGrantsUP.json',JSON.stringify(oauth2Grants)) -------------------------------------------------------------------------------- /schemaForExternalData.js: -------------------------------------------------------------------------------- 1 | var pathLoc = 'material' 2 | var fs = require('fs') 3 | var path = require('path') 4 | var {createStorage} = require('./SchemaStorage') 5 | //var chalk = require('chalk') 6 | 7 | main() 8 | 9 | async function main ( ) { 10 | 11 | var files = fs.readdirSync(path.resolve(pathLoc)) 12 | var tid = fs.readFileSync('kql/tid.txt').toString() 13 | var fullSchema = `let home="${tid}"; \n //` 14 | for await (file of files) { 15 | 16 | var content = require(`./${pathLoc}/${file}`).filter(app => app.appDisplayName !== null) 17 | //console.log( chalk.yellow('\nschema for', file, '\n' )) 18 | var schema = `\nlet ${file.split('.json')[0]} = (externaldata (` 19 | 20 | try {delete content[0]['@odata.id']} catch (error) { 21 | console.log('different schema') 22 | } 23 | 24 | var k = Object.keys(content[0]) 25 | k.forEach((key, index) => { 26 | 27 | if (key.match('appDisplayName')) { 28 | console.log() 29 | } 30 | 31 | var type = typeof(content[0][key]) 32 | /* console.log(content[0][key]) */ 33 | if (type == "object") { 34 | schema += `${key}: dynamic` 35 | } else { 36 | schema += `${key}: string` 37 | } 38 | 39 | if (index !== (k.length - 1)) { 40 | schema += ", " 41 | } 42 | }) 43 | var url = await createStorage(pathLoc,file,`./${pathLoc}/${file}`) 44 | schema += `)[@"${url}"] with (format="multijson"));` 45 | schema += '\n //' 46 | fullSchema+=schema 47 | 48 | } 49 | let callIt = fs.readFileSync('kql/call.kql').toString() 50 | var baseq = fs.readFileSync('kql/query.kql').toString() 51 | fs.writeFileSync('kql/runtime.kql',fullSchema+baseq+callIt) 52 | 53 | } 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /kql/apiSort.kql: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // 4 | // 5 | let mstenant = split("72f988bf-86f1-41af-91ab-2d7cd011db47,0d2db716-b331-4d7b-aa37-7f1ac9d35dae,f52f6d19-877e-4eaf-87da-9da27954c544,f8cdef31-a31e-4b4a-93e4-5f571e91255a",","); 6 | let sd = rolesUP 7 | | join kind=inner (servicePrincipalsUP | project spnId =['id'], resourceOrg = appOwnerOrganizationId) on $left.resourceId == $right.spnId 8 | | extend Principal = principalDisplayName 9 | | extend assigment = strcat(assignedRole) 10 | | extend assigmentId = ['id'] 11 | | extend displayName = iff(principalType == "ServicePrincipal",Principal, resourceDisplayName) 12 | | extend assigmentL = parse_json(assignedRole) 13 | | mv-expand assigmentL 14 | | where isnotempty(assigmentL.value) 15 | | extend assigment = tostring(assigmentL.value) 16 | | extend assigmentId = ['id'] 17 | | extend clientMatch = iff(principalType contains "Group" or principalType contains "User", resourceId, principalId) 18 | | join kind=inner (servicePrincipalsUP | project ['id'], homeOrganization=appOwnerOrganizationId, owners, appRoleAssignmentRequired) on $left.clientMatch == $right.['id'] 19 | | extend AppType = case(mstenant contains homeOrganization and isnotempty( homeOrganization), 'Native MS', homeOrganization == home, "SingleTenant", isempty(homeOrganization), "managedIdentity", "MultiTenant") 20 | | extend RolescombinedAssignment = strcat(resourceDisplayName, '-', assigment) 21 | | summarize make_set(displayName) by RolescombinedAssignment, AppType; 22 | let gr = oauth2PermissionGrantsUP 23 | | join kind=inner servicePrincipalsUP on $left.clientId == $right.['id'] 24 | | extend Principal = iff(isempty( principalId), consentType, userPrincipalName) 25 | | extend AppType = case(mstenant contains appOwnerOrganizationId, 'Native MS', appOwnerOrganizationId == home, "SingleTenant", "MultiTenant") 26 | | extend assigment = scope 27 | | extend principalId = iff(isempty( principalId), clientId, principalId) 28 | | extend RolescombinedAssignment = strcat(resourceDisplayName, '-', assigment) 29 | | summarize make_set(displayName) by RolescombinedAssignment, tostring(preferredSingleSignOnMode), AppType; 30 | union sd, gr 31 | | extend countOfApps = array_length(set_displayName) 32 | | where isempty( preferredSingleSignOnMode) 33 | | project-away preferredSingleSignOnMode -------------------------------------------------------------------------------- /admins.js: -------------------------------------------------------------------------------- 1 | const { axiosClient } = require("./src/axioshelpers") 2 | const { batchThrottledSimple } = require("./src/batcher2") 3 | const getToken = require("./src/getToken") 4 | 5 | module.exports={admins} 6 | //admins() 7 | async function admins () { 8 | 9 | var graphToken = await getToken() 10 | 11 | var {value:roles} = await genericGraph({ 12 | responseType: 'json', 13 | "method": "get", 14 | url: `https://graph.microsoft.com/beta/directoryRoles/`, 15 | headers: { 16 | 'content-type': "application/json", 17 | authorization: "Bearer " + graphToken 18 | } 19 | }).catch((error) => { 20 | console.log(error) 21 | }) 22 | 23 | roles.map((item) => { 24 | 25 | item.runContext= { 26 | fn: genericGraph, 27 | opts:{ 28 | refInfo:item?.displayName, 29 | responseType: 'json', 30 | "method": "get", 31 | url:`https://graph.microsoft.com/beta/directoryRoles/${item.id}/members`, 32 | headers:{ 33 | 'content-type':"application/json", 34 | authorization:"Bearer " + graphToken 35 | }, 36 | /* timeout:2000 */ 37 | } 38 | } 39 | }) 40 | 41 | 42 | let admins = await batchThrottledSimple(7,roles) 43 | 44 | var list =[] 45 | admins.map(it => { 46 | it.value.filter(ob => ob['@odata.type'] !== '#microsoft.graph.user' ).forEach(spn => { 47 | let {appId, id, displayName} = spn 48 | list.push({id, displayName, appId, role:it.refInfo }) 49 | }) 50 | }) 51 | 52 | if (list.length > 0) { 53 | require('fs').writeFileSync('./material/admins.json',JSON.stringify(list)) 54 | return "admin completed" 55 | } 56 | 57 | 58 | require('fs').writeFileSync('./material/admins.json',`[{"role":"","displayName":""}]`) 59 | 60 | } 61 | 62 | 63 | 64 | async function genericGraph (options) { 65 | console.log(options.url) 66 | if (options?.refInfo) { 67 | var {refInfo} = options 68 | delete options.refInfo 69 | } 70 | var data = await axiosClient(options).catch((error) => { 71 | return Promise.reject(error) 72 | }) 73 | 74 | if (refInfo) { 75 | data.refInfo=refInfo 76 | return data 77 | } else { 78 | return data 79 | } 80 | } -------------------------------------------------------------------------------- /src/src.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | //const chalk = require('chalk') 3 | 4 | var sta = require('./config.json').connectionString 5 | //console.log(sta) 6 | process.env['AZURE_STORAGE_CONNECTION_STRING']=sta 7 | const {createBlobService,BlobUtilities} = require('azure-storage') 8 | 9 | const blobsvc = createBlobService() 10 | 11 | //console.log('init') 12 | 13 | function getSasUrl (container,name,duration,IPAddressOrRange) { 14 | 15 | var d1 = new Date (), 16 | d2 = new Date ( d1 ); 17 | d2.setMinutes ( d1.getMinutes() + duration ); 18 | 19 | var sharedAccessPolicy = { 20 | AccessPolicy: { 21 | Permissions: BlobUtilities.SharedAccessPermissions.READ, 22 | Start: d1, 23 | Expiry: d2, 24 | Protocols:"https", 25 | } 26 | }; 27 | 28 | console.log(sharedAccessPolicy) 29 | 30 | if (IPAddressOrRange ) { 31 | sharedAccessPolicy.AccessPolicy.IPAddressOrRange = IPAddressOrRange 32 | } 33 | 34 | var sas = blobsvc.generateSharedAccessSignature(container,name,sharedAccessPolicy) 35 | 36 | var url = blobsvc.getUrl(container,name,sas) 37 | 38 | 39 | return (url) 40 | /* 41 | var sas = BlobClientWithToken.generateSharedAccessSignature(container,,sharedAccessPolicy) 42 | var url = blobsvc.getUrl(container, upload, cred); */ 43 | 44 | } 45 | 46 | 47 | function createContainer (container) { 48 | 49 | return new Promise ((resolve,reject) => { 50 | 51 | blobsvc.createContainerIfNotExists(container, (err,result) => { 52 | // console.log(result) 53 | 54 | if (err) { 55 | return reject(err) 56 | } 57 | 58 | return resolve(result) 59 | }) 60 | 61 | }) 62 | 63 | 64 | 65 | } 66 | 67 | async function upload (container,file,filepath) { 68 | 69 | return new Promise ((resolve,reject )=> { 70 | 71 | blobsvc.createBlockBlobFromLocalFile(container,file,filepath,(err,result,response) => { 72 | //console.log(result) 73 | 74 | if (err) {return reject(err)} 75 | 76 | return resolve(result) 77 | }) 78 | 79 | }) 80 | 81 | 82 | 83 | } 84 | 85 | //reads() 86 | module.exports={createContainer,upload,getSasUrl,createContainer} -------------------------------------------------------------------------------- /nodeparse2.js: -------------------------------------------------------------------------------- 1 | var users = require('./users.json') 2 | var oauth2Grants = require('./oauth2PermissionGrants.json') 3 | var spns = require('./servicePrincipals.json') 4 | var applications = require('./applications-save.json') 5 | const { getWebSitesName } = require('./src/dnslook') 6 | 7 | main() 8 | 9 | async function main () { 10 | 11 | 12 | oauth2Grants.map((item) => { 13 | 14 | var match = users.find((user) => user.id == item.principalId) 15 | 16 | item.userPrincipalName = match?.userPrincipalName || null 17 | return item 18 | } ) 19 | 20 | oauth2Grants.map((item) => { 21 | 22 | var match = spns.find((spn) => spn.id == item.resourceId) 23 | 24 | item.resourceDisplayName = match.displayName || null 25 | 26 | }) 27 | 28 | spns.map( (item) =>{ 29 | 30 | var pw = applications.find((spn) => spn.appId == item.appId ) 31 | 32 | item.ApplicationHasPassword = pw?.passwordCredentials || item.passwordCredentials 33 | item.ApplicationHasPublicClient = pw?.isFallbackPublicClient || "" 34 | 35 | item.ApplicationHasRequiredAccess = pw?.requiredResourceAccess.map((res) => { 36 | 37 | var spm =spns.find((spn) => spn.appId == res.resourceAppId) 38 | 39 | res.resourceAccess = res?.resourceAccess || [] 40 | if (spm) { 41 | return res?.resourceAccess.map(({id}) => { 42 | spm.appRoles = spm?.appRoles || [] 43 | //console.log(spm.appRoles.length) 44 | spm.oauth2PermissionScopes = spm?.oauth2PermissionScopes || [] 45 | var oauth2R = spm?.oauth2PermissionScopes.find((role) => role.id == id) 46 | if (oauth2R) { 47 | oauth2R.resource = spm?.appDisplayName || [] 48 | return oauth2R 49 | } 50 | }) || [] 51 | } 52 | 53 | 54 | }) 55 | 56 | 57 | 58 | delete item.appRoles; delete item.oauth2PermissionScopes 59 | 60 | }) 61 | 62 | 63 | var rs = [] 64 | 65 | spns.map( spn => { 66 | spn.replyUrls.filter(item => item.match('azurewebsites.net')).forEach(item => { 67 | rs.push(getWebSitesName({fqdn:item,id:spn.id})) 68 | }) 69 | }) 70 | 71 | var s= await Promise.all(rs) 72 | var dangl = s.filter(item => item.failed == true) 73 | spns.map(item => { 74 | 75 | item.danglingRedirect= dangl.filter(spn => spn.id == item.id) || undefined || {} 76 | }) 77 | 78 | /* spns.push(require('./material/doNotRemove.json')[2]) 79 | 80 | 81 | spns.reverse() */ 82 | 83 | 84 | // console.log(oauth2Grants) 85 | 86 | require('fs').writeFileSync('./material/servicePrincipalsUP.json',JSON.stringify(spns)) 87 | 88 | require('fs').writeFileSync('./material/oauth2PermissionGrantsUP.json',JSON.stringify(oauth2Grants)) 89 | 90 | } 91 | -------------------------------------------------------------------------------- /material/doNotRemove.json: -------------------------------------------------------------------------------- 1 | [{"test":1},{"test":2},{ 2 | "id": "0048d536-cd74-4242-b97b-55b2c0b42acb", 3 | "deletedDateTime": null, 4 | "accountEnabled": true, 5 | "alternativeNames": [], 6 | "appDisplayName": "EXAMPLE APP Not In your Tenant akoyqyqnwir-main-api ", 7 | "appDescription": null, 8 | "appId": "daf4bf56-a102-4d4a-a9ef-572da8367884", 9 | "applicationTemplateId": null, 10 | "appOwnerOrganizationId": "033794f5-7c9d-4e98-923d-7b49114b7ac3", 11 | "appRoleAssignmentRequired": false, 12 | "createdDateTime": "2021-09-23T05:34:11Z", 13 | "description": null, 14 | "disabledByMicrosoftStatus": null, 15 | "displayName": "EXAMPLE APP Not In your Tenant akoyqyqnwir-main-api ", 16 | "homepage": null, 17 | "loginUrl": null, 18 | "logoutUrl": null, 19 | "notes": null, 20 | "notificationEmailAddresses": [], 21 | "preferredSingleSignOnMode": null, 22 | "preferredTokenSigningKeyThumbprint": null, 23 | "replyUrls": ["https://az.dewi.red/token"], 24 | "servicePrincipalNames": ["https://attacker.thx.dewi.red", "daf4bf56-a102-4d4a-a9ef-572da8367884"], 25 | "servicePrincipalType": "Application", 26 | "signInAudience": "AzureADMultipleOrgs", 27 | "tags": [], 28 | "tokenEncryptionKeyId": null, 29 | "samlSingleSignOnSettings": null, 30 | "verifiedPublisher": { 31 | "displayName": null, 32 | "verifiedPublisherId": null, 33 | "addedDateTime": null 34 | }, 35 | "addIns": [], 36 | "info": { 37 | "logoUrl": null, 38 | "marketingUrl": null, 39 | "privacyStatementUrl": null, 40 | "supportUrl": null, 41 | "termsOfServiceUrl": null 42 | }, 43 | "keyCredentials": [], 44 | "passwordCredentials": [], 45 | "resourceSpecificApplicationPermissions": [], 46 | "owners": { 47 | "userPrincipalName": ["joosua@thx138.onmicrosoft.com"] 48 | }, 49 | "ApplicationHasPassword": [{ 50 | "customKeyIdentifier": null, 51 | "displayName": "Password uploaded on Tue Dec 14 2021", 52 | "endDateTime": "2022-06-14T12:39:33.895Z", 53 | "hint": "F2W", 54 | "keyId": "e6476559-77aa-42d6-8a62-da013c7fdbc3", 55 | "secretText": null, 56 | "startDateTime": "2021-12-14T13:39:33.895Z" 57 | }], 58 | "ApplicationHasPublicClient": "", 59 | "ApplicationHasRequiredAccess": [ 60 | [{ 61 | "adminConsentDescription": "Allow this application to access Log Analytics data on behalf of the user", 62 | "adminConsentDisplayName": "Read Log Analytics data as user", 63 | "id": "e8dac03d-d467-4a7e-9293-9cca7df08b31", 64 | "isEnabled": true, 65 | "type": "User", 66 | "userConsentDescription": "Allow this application to access your Log Analytics Data", 67 | "userConsentDisplayName": "Read Log Analytics Data", 68 | "value": "Data.Read", 69 | "resource": "Log Analytics API" 70 | }], 71 | [{ 72 | "adminConsentDescription": "Allows users to sign in to the app with their work or school accounts and allows the app to see basic user profile information.", 73 | "adminConsentDisplayName": "Sign users in", 74 | "id": "37f7f235-527c-4136-accd-4a02d197296e", 75 | "isEnabled": true, 76 | "type": "User", 77 | "userConsentDescription": "Allows you to sign in to the app with your work or school account and allows the app to read your basic profile information.", 78 | "userConsentDisplayName": "Sign in as you", 79 | "value": "openid", 80 | "resource": "Microsoft Graph" 81 | }] 82 | ], 83 | "appRoles": [], 84 | "oauth2PermissionScopes": [], 85 | "danglingRedirect": [] 86 | }] -------------------------------------------------------------------------------- /kql/withRequiredAccess.kql: -------------------------------------------------------------------------------- 1 | // //// 2 | let pwInfo= servicePrincipalsUP 3 | | mv-expand ApplicationHasPassword 4 | | mv-apply ApplicationHasPassword.endDateTime on ( 5 | extend when =(datetime_diff('day',todatetime(ApplicationHasPassword_endDateTime),now())) 6 | | extend HasExpiringPassword = when < 30 7 | | extend info = ApplicationHasPassword_endDateTime 8 | ) 9 | | extend expiredOrExpiringPW =strcat(HasExpiringPassword, ':', when) 10 | | summarize make_set(expiredOrExpiringPW) by appDisplayName, appId; 11 | let mstenant = split("72f988bf-86f1-41af-91ab-2d7cd011db47,0d2db716-b331-4d7b-aa37-7f1ac9d35dae,f52f6d19-877e-4eaf-87da-9da27954c544,f8cdef31-a31e-4b4a-93e4-5f571e91255a",","); 12 | let roles=rolesUP 13 | | join kind=inner (servicePrincipalsUP | project ['id'], resourceOrg = appOwnerOrganizationId, replyUrls, ApplicationHasRequiredAccess) on $left.resourceId == $right.['id'] 14 | | extend Principal = principalDisplayName 15 | | extend assigment = strcat(assignedRole) 16 | | extend clientDisplay = iff(principalType == "ServicePrincipal",Principal, resourceDisplayName) 17 | | extend assigmentL = parse_json(assignedRole) 18 | | mv-expand assigmentL 19 | | where isnotempty(assigmentL.value) 20 | | extend assigment = tostring(assigmentL.value) 21 | | extend clientMatch = iff(principalType contains "Group" or principalType contains "User", resourceId, principalId) 22 | | join kind=inner (servicePrincipalsUP | project ['id'], homeOrganization=appOwnerOrganizationId,replyUrls, owners, ApplicationHasRequiredAccess, appRoleAssignmentRequired) on $left.clientMatch == $right.['id'] 23 | | extend AppType = case(mstenant contains homeOrganization and isnotempty( homeOrganization), 'Native MS', homeOrganization == home, "SingleTenant", isempty(homeOrganization), "managedIdentity", "MultiTenant") 24 | | extend RolescombinedAssignment = strcat(resourceDisplayName,' : permissions - ', principalType, ':Principal:', Principal,'-', assigment) 25 | | summarize make_set(RolescombinedAssignment) by clientDisplay, clientMatch, resourceOrg, AppType, tostring(owners), tostring(replyUrls), tostring(ApplicationHasRequiredAccess), appRoleAssignmentRequired 26 | | extend hasWritePermissions = iff(set_RolescombinedAssignment contains "write", "True", "False"); 27 | // 28 | // 29 | // 30 | let users =oauth2PermissionGrantsUP 31 | | join kind=inner servicePrincipalsUP on $left.clientId == $right.['id'] 32 | | extend Principal = iff(isempty( principalId), consentType, userPrincipalName) 33 | | extend AppType = case(mstenant contains appOwnerOrganizationId, 'Native MS', appOwnerOrganizationId == home, "SingleTenant", "MultiTenant") 34 | | extend assigment = scope 35 | | extend principalId = iff(isempty( principalId), clientId, principalId) 36 | | extend UsersCombinedAssignment = strcat(Principal, '-', resourceDisplayName, ' : permissions - ', assigment) 37 | | summarize make_set(UsersCombinedAssignment) by clientDisplay = displayName, AppType, tostring(owners), appOwnerOrganizationId, servicePrincipalType, tostring(replyUrls), tostring(ApplicationHasRequiredAccess), appRoleAssignmentRequired 38 | | extend UserAdminGrant = iff(set_UsersCombinedAssignment contains "AllPrincipals", "True", "False") 39 | | extend hasWritePermissions = iff(set_UsersCombinedAssignment contains "write", "True", "False") 40 | | project-away servicePrincipalType; 41 | //Join all roles and apppermissions to user permissions 42 | let full = ['roles'] 43 | | join kind=fullouter ['users'] on clientDisplay 44 | | where isnotempty( clientDisplay); 45 | //ensure user permissions that were not matched are on the same table 46 | let missed = ['users'] 47 | | join kind=leftanti ['full'] on clientDisplay; 48 | let f= union missed, full 49 | | extend appRegOwners = owners 50 | | project-away AppType1, clientDisplay1, hasWritePermissions1, appOwnerOrganizationId, owners, owners1, replyUrls1 51 | | join kind=fullouter pwInfo on $left.clientDisplay == $right.appDisplayName 52 | | where isnotempty( clientDisplay) 53 | | extend hasExpiringOrExpiredPW = iff(tostring( set_expiredOrExpiringPW) contains "True", "True","False"); 54 | f -------------------------------------------------------------------------------- /kql/debug.kql: -------------------------------------------------------------------------------- 1 | // /////// 2 | let pwInfo= servicePrincipalsUP 3 | | mv-expand ApplicationHasPassword 4 | | mv-apply ApplicationHasPassword.endDateTime on ( 5 | extend when =(datetime_diff('day',todatetime(ApplicationHasPassword_endDateTime),now())) 6 | | extend HasExpiringPassword = when < 30 7 | | extend info = ApplicationHasPassword_endDateTime 8 | ) 9 | | extend expiredOrExpiringPW =strcat(HasExpiringPassword, ':', when) 10 | | summarize make_set(expiredOrExpiringPW) by appDisplayName, appId, ApplicationHasPublicClient; 11 | let mstenant = split("72f988bf-86f1-41af-91ab-2d7cd011db47,0d2db716-b331-4d7b-aa37-7f1ac9d35dae,f52f6d19-877e-4eaf-87da-9da27954c544,f8cdef31-a31e-4b4a-93e4-5f571e91255a",","); 12 | // 13 | // 14 | // 15 | let roles=rolesUP 16 | | join kind=inner (servicePrincipalsUP | project spnId =['id'], resourceOrg = appOwnerOrganizationId) on $left.resourceId == $right.spnId 17 | | extend Principal = principalDisplayName 18 | | extend assigment = strcat(assignedRole) 19 | | extend assigmentId = ['id'] 20 | | extend clientDisplay = iff(principalType == "ServicePrincipal",Principal, resourceDisplayName) 21 | | extend assigmentL = parse_json(assignedRole) 22 | | mv-expand assigmentL 23 | | where isnotempty(assigmentL.value) 24 | | extend assigment = tostring(assigmentL.value) 25 | | extend assigmentId = ['id'] 26 | | extend clientMatch = iff(principalType contains "Group" or principalType contains "User", resourceId, principalId) 27 | | join kind=inner (servicePrincipalsUP | project ['id'], homeOrganization=appOwnerOrganizationId, owners, appRoleAssignmentRequired) on $left.clientMatch == $right.['id'] 28 | | extend AppType = case(mstenant contains homeOrganization and isnotempty( homeOrganization), 'Native MS', homeOrganization == home, "SingleTenant", isempty(homeOrganization), "managedIdentity", "MultiTenant") 29 | | extend RolescombinedAssignment = strcat(clientMatch, resourceDisplayName,' : permissions - ', principalType, ':Principal:', Principal,'-',assigment, ' - ', assigmentId) 30 | | summarize make_set(RolescombinedAssignment) by clientDisplay, resourceOrg, AppType, tostring(owners), appRoleAssignmentRequired 31 | | extend hasWritePermissions = iff(set_RolescombinedAssignment contains "write", "True", "False"); 32 | // 33 | // 34 | // 35 | let fullUsers =oauth2PermissionGrantsUP 36 | | join kind=inner servicePrincipalsUP on $left.clientId == $right.['id'] 37 | | extend Principal = iff(isempty( principalId), consentType, userPrincipalName) 38 | | extend AppType = case(mstenant contains appOwnerOrganizationId, 'Native MS', appOwnerOrganizationId == home, "SingleTenant", "MultiTenant") 39 | | extend assigment = scope 40 | | extend principalId = iff(isempty( principalId), clientId, principalId) 41 | | extend UsersCombinedAssignment = strcat(clientId, '-' ,Principal, '-', resourceDisplayName, ' : permissions - ', assigment, ' - ',['id']) 42 | | summarize make_set(UsersCombinedAssignment) by clientDisplay = displayName, AppType, tostring(owners), appOwnerOrganizationId, servicePrincipalType, appRoleAssignmentRequired, tostring(danglingRedirect) 43 | | extend UserAdminGrant = iff(set_UsersCombinedAssignment contains "AllPrincipals", "True", "False") 44 | | extend hasWritePermissions = iff(set_UsersCombinedAssignment contains "write", "True", "False") 45 | | project-away servicePrincipalType; 46 | let replies =oauth2PermissionGrantsUP 47 | | join kind=inner servicePrincipalsUP on $left.clientId == $right.['id'] 48 | | extend replyUrl = strcat(clientId, '-', replyUrls) 49 | | summarize make_set(replyUrl) by clientDisplay = displayName; 50 | let users = replies 51 | | join kind=inner ['fullUsers'] on clientDisplay; 52 | // 53 | // 54 | let full = ['roles'] 55 | | join kind=fullouter ['users'] on clientDisplay 56 | | where isnotempty( clientDisplay); 57 | //ensure user permissions that were not matched are on the same table 58 | let missed = ['users'] 59 | | join kind=leftanti ['full'] on clientDisplay; 60 | let f= union missed, full 61 | | extend appRegOwners = owners 62 | | project-away AppType1, clientDisplay1, hasWritePermissions1, appOwnerOrganizationId, owners, appRoleAssignmentRequired1, owners1 63 | | join kind=fullouter pwInfo on $left.clientDisplay == $right.appDisplayName 64 | | project-away appDisplayName 65 | | where isnotempty( clientDisplay); 66 | let final = f 67 | | join kind=fullouter (['admins'] |project aadAdminRole=role, displayName 68 | | summarize make_set(aadAdminRole) by displayName ) on $left.clientDisplay == $right.displayName 69 | | where isnotempty( clientDisplay) 70 | | extend warningAppPriv =iff(AppType == "MultiTenant" and isnotempty( set_RolescombinedAssignment), "true","false"); 71 | -------------------------------------------------------------------------------- /kql/queryAppprx.kql: -------------------------------------------------------------------------------- 1 | // /////// 2 | let pwInfo= servicePrincipalsUP 3 | | mv-expand ApplicationHasPassword 4 | | mv-apply ApplicationHasPassword.endDateTime on ( 5 | extend when =(datetime_diff('day',todatetime(ApplicationHasPassword_endDateTime),now())) 6 | | extend HasExpiringPassword = when < 30 7 | | extend info = ApplicationHasPassword_endDateTime 8 | ) 9 | | extend expiredOrExpiringPW =strcat(HasExpiringPassword, ':', when) 10 | | summarize make_set(expiredOrExpiringPW) by appDisplayName, appId, ApplicationHasPublicClient; 11 | let mstenant = split("72f988bf-86f1-41af-91ab-2d7cd011db47,0d2db716-b331-4d7b-aa37-7f1ac9d35dae,f52f6d19-877e-4eaf-87da-9da27954c544,f8cdef31-a31e-4b4a-93e4-5f571e91255a",","); 12 | // 13 | // 14 | // 15 | let roles=rolesUP 16 | | join kind=inner (servicePrincipalsUP | project spnId =['id'], resourceOrg = appOwnerOrganizationId) on $left.resourceId == $right.spnId 17 | | extend Principal = principalDisplayName 18 | | extend assigment = strcat(assignedRole) 19 | | extend assigmentId = ['id'] 20 | | extend clientDisplay = iff(principalType == "ServicePrincipal",Principal, resourceDisplayName) 21 | | extend assigmentL = parse_json(assignedRole) 22 | | mv-expand assigmentL 23 | | where isnotempty(assigmentL.value) 24 | | extend assigment = tostring(assigmentL.value) 25 | | extend assigmentId = ['id'] 26 | | extend clientMatch = iff(principalType contains "Group" or principalType contains "User", resourceId, principalId) 27 | | join kind=inner (servicePrincipalsUP | project ['id'], homeOrganization=appOwnerOrganizationId, owners, appRoleAssignmentRequired) on $left.clientMatch == $right.['id'] 28 | | extend AppType = case(mstenant contains homeOrganization and isnotempty( homeOrganization), 'Native MS', homeOrganization == home, "SingleTenant", isempty(homeOrganization), "managedIdentity", "MultiTenant") 29 | | extend RolescombinedAssignment = strcat(clientMatch, resourceDisplayName,' : permissions - ', principalType, ':Principal:', Principal,'-',assigment, ' - ', assigmentId) 30 | | summarize make_set(RolescombinedAssignment) by clientDisplay, resourceOrg, AppType, tostring(owners), appRoleAssignmentRequired 31 | | extend hasWritePermissions = iff(set_RolescombinedAssignment contains "write", "True", "False"); 32 | // 33 | // 34 | // 35 | let fullUsers =oauth2PermissionGrantsUP 36 | | join kind=inner servicePrincipalsUP on $left.clientId == $right.['id'] 37 | | extend Principal = iff(isempty( principalId), consentType, userPrincipalName) 38 | | extend AppType = case(mstenant contains appOwnerOrganizationId, 'Native MS', appOwnerOrganizationId == home, "SingleTenant", "MultiTenant") 39 | | extend assigment = scope 40 | | extend principalId = iff(isempty( principalId), clientId, principalId) 41 | | extend UsersCombinedAssignment = strcat(clientId, '-' ,Principal, '-', resourceDisplayName, ' : permissions - ', assigment, ' - ',['id']) 42 | | summarize make_set(UsersCombinedAssignment) by clientDisplay = displayName, AppType, tostring(owners), appOwnerOrganizationId, servicePrincipalType, appRoleAssignmentRequired, tostring(danglingRedirect), tostring(onPremisesPublishing) 43 | | extend UserAdminGrant = iff(set_UsersCombinedAssignment contains "AllPrincipals", "True", "False") 44 | | extend hasWritePermissions = iff(set_UsersCombinedAssignment contains "write", "True", "False") 45 | | project-away servicePrincipalType; 46 | let replies =oauth2PermissionGrantsUP 47 | | join kind=inner servicePrincipalsUP on $left.clientId == $right.['id'] 48 | | extend replyUrl = strcat(clientId, '-', replyUrls) 49 | | summarize make_set(replyUrl) by clientDisplay = displayName; 50 | let users = replies 51 | | join kind=inner ['fullUsers'] on clientDisplay; 52 | // 53 | // 54 | let full = ['roles'] 55 | | join kind=fullouter ['users'] on clientDisplay 56 | | where isnotempty( clientDisplay); 57 | //ensure user permissions that were not matched are on the same table 58 | let missed = ['users'] 59 | | join kind=leftanti ['full'] on clientDisplay; 60 | let f= union missed, full 61 | | extend appRegOwners = owners 62 | | project-away AppType1, clientDisplay1, hasWritePermissions1, appOwnerOrganizationId, owners, appRoleAssignmentRequired1, owners1 63 | | join kind=fullouter pwInfo on $left.clientDisplay == $right.appDisplayName 64 | | project-away appDisplayName 65 | | where isnotempty( clientDisplay); 66 | let final = f 67 | | join kind=fullouter (['admins'] |project aadAdminRole=role, displayName 68 | | summarize make_set(aadAdminRole) by displayName ) on $left.clientDisplay == $right.displayName 69 | | where isnotempty( clientDisplay) 70 | | extend warningAppPriv =iff(AppType == "MultiTenant" and isnotempty( set_RolescombinedAssignment), "true","false"); 71 | -------------------------------------------------------------------------------- /kql/query.kql: -------------------------------------------------------------------------------- 1 | // //////// /////// 2 | let pwInfo= servicePrincipalsUP 3 | | mv-expand ApplicationHasPassword 4 | | mv-apply ApplicationHasPassword.endDateTime on ( 5 | extend when =(datetime_diff('day',todatetime(ApplicationHasPassword_endDateTime),now())) 6 | | extend HasExpiringPassword = when < 30 7 | | extend info = ApplicationHasPassword_endDateTime 8 | ) 9 | | extend expiredOrExpiringPW =strcat(HasExpiringPassword, ':', when) 10 | | summarize make_set(expiredOrExpiringPW) by appDisplayName, appId, ApplicationHasPublicClient; 11 | let mstenant = split("72f988bf-86f1-41af-91ab-2d7cd011db47,0d2db716-b331-4d7b-aa37-7f1ac9d35dae,f52f6d19-877e-4eaf-87da-9da27954c544,f8cdef31-a31e-4b4a-93e4-5f571e91255a",","); 12 | // 13 | // 14 | // 15 | let roles=rolesUP 16 | | join kind=inner (servicePrincipalsUP | project spnId =['id'], resourceOrg = appOwnerOrganizationId, signInAudience) on $left.resourceId == $right.spnId 17 | | extend Principal = principalDisplayName 18 | | extend assigment = strcat(assignedRole) 19 | | extend assigmentId = ['id'] 20 | | extend clientDisplay = iff(principalType == "ServicePrincipal",Principal, resourceDisplayName) 21 | | extend assigmentL = parse_json(assignedRole) 22 | | mv-expand assigmentL 23 | | where isnotempty(assigmentL.value) 24 | | extend assigment = tostring(assigmentL.value) 25 | | extend assigmentId = ['id'] 26 | | extend clientMatch = iff(principalType contains "Group" or principalType contains "User", resourceId, principalId) 27 | | join kind=inner (servicePrincipalsUP | project ['id'], homeOrganization=appOwnerOrganizationId, owners, appRoleAssignmentRequired) on $left.clientMatch == $right.['id'] 28 | | extend AppType = case(mstenant contains homeOrganization and isnotempty( homeOrganization), 'Native MS', homeOrganization == home, "SingleTenant", isempty(homeOrganization), "managedIdentity", "MultiTenant") 29 | | extend RolescombinedAssignment = strcat(resourceDisplayName,' : permissions - ', principalType, ' - ', Principal,'-',assigment) 30 | | summarize make_set(RolescombinedAssignment) by clientDisplay, resourceOrg, AppType, tostring(owners), appRoleAssignmentRequired 31 | | extend hasWritePermissions = iff(set_RolescombinedAssignment contains "write", "True", "False"); 32 | // 33 | // 34 | // 35 | let fullUsers =oauth2PermissionGrantsUP 36 | | join kind=inner servicePrincipalsUP on $left.clientId == $right.['id'] 37 | | extend Principal = iff(isempty( principalId), consentType, userPrincipalName) 38 | | extend AppType = case(mstenant contains appOwnerOrganizationId, 'Native MS', appOwnerOrganizationId == home, "SingleTenant", "MultiTenant") 39 | | extend assigment = scope 40 | | extend principalId = iff(isempty( principalId), clientId, principalId) 41 | | extend UsersCombinedAssignment = strcat(resourceDisplayName, '- API -', assigment , '-', Principal) 42 | | summarize make_set(UsersCombinedAssignment) by clientDisplay = displayName, AppType, tostring(owners), appOwnerOrganizationId, servicePrincipalType, appRoleAssignmentRequired, tostring(danglingRedirect) 43 | | extend UserAdminGrant = iff(set_UsersCombinedAssignment contains "AllPrincipals", "True", "False") 44 | | extend hasWritePermissions = iff(set_UsersCombinedAssignment contains "write", "True", "False") 45 | | project-away servicePrincipalType; 46 | let replies =oauth2PermissionGrantsUP 47 | | join kind=inner servicePrincipalsUP on $left.clientId == $right.['id'] 48 | | summarize make_set(replyUrls) by clientDisplay = displayName; 49 | let users = replies 50 | | join kind=inner ['fullUsers'] on clientDisplay; 51 | // 52 | // 53 | let full = ['roles'] 54 | | join kind=fullouter ['users'] on clientDisplay 55 | | where isnotempty( clientDisplay); 56 | //ensure user permissions that were not matched are on the same table 57 | let missed = ['users'] 58 | | join kind=leftanti ['full'] on clientDisplay; 59 | let f= union missed, full 60 | | extend appRegOwners = owners 61 | | project-away AppType1, clientDisplay1, hasWritePermissions1, appOwnerOrganizationId, owners, appRoleAssignmentRequired1, owners1 62 | | join kind=fullouter pwInfo on $left.clientDisplay == $right.appDisplayName 63 | | project-away appDisplayName 64 | | where isnotempty( clientDisplay); 65 | let final = f 66 | | join kind=fullouter (['admins'] |project aadAdminRole=role, displayName 67 | | summarize make_set(aadAdminRole) by displayName ) on $left.clientDisplay == $right.displayName 68 | | where isnotempty( clientDisplay) 69 | | extend warningAppPriv =iff(AppType == "MultiTenant" and isnotempty( set_RolescombinedAssignment), "true","false") 70 | | project-away displayName,clientDisplay2 71 | | join kind= fullouter (servicePrincipalsUP | project appDisplayName, signInAudience) on $left.clientDisplay == $right.appDisplayName 72 | | extend signInAudience = iff(isempty(signInAudience) and AppType !contains "managedIdentity", 'Review appType manually !', signInAudience) 73 | | where isnotempty( clientDisplay) 74 | | extend AppType = iff(signInAudience !has "AzureADMyOrg" and AppType == "SingleTenant", strcat(AppType, '-', signInAudience), AppType) 75 | | project-away signInAudience; -------------------------------------------------------------------------------- /src/graphf.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const { decode } = require('jsonwebtoken'); 4 | const { axiosClient } = require('./axioshelpers'); 5 | 6 | async function graph (token, operation) { 7 | 8 | console.log('checking', operation) 9 | 10 | var options = { 11 | responseType: 'json', 12 | "method": "get", 13 | url:`${token.resource}/v1.0/${operation}`, 14 | headers:{ 15 | 'content-type':"application/json", 16 | authorization:"bearer " + token['access_token'] 17 | }, 18 | /* timeout:2000 */ 19 | } 20 | 21 | options 22 | var data = await axiosClient(options).catch((error) => { 23 | return Promise.reject(error) 24 | }) 25 | 26 | return data?.value || data 27 | 28 | } 29 | 30 | async function graphExtended (token, operation) { 31 | 32 | console.log('checking', operation) 33 | 34 | 35 | var options = { 36 | responseType: 'json', 37 | "method": "get", 38 | url:`https://graph.microsoft.com/beta/${operation}`, 39 | headers:{ 40 | 'content-type':"application/json", 41 | authorization:"bearer " + token['access_token'] 42 | }, 43 | /* timeout:2000 */ 44 | } 45 | 46 | options 47 | var data = await axiosClient(options).catch((error) => { 48 | return Promise.reject(error) 49 | }) 50 | 51 | return data?.value || data 52 | 53 | } 54 | 55 | async function graphOwner (token, operation,appId) { 56 | 57 | console.log('checking', operation) 58 | 59 | var options = { 60 | responseType: 'json', 61 | "method": "get", 62 | url:`${token.resource}/v1.0/${operation}`, 63 | headers:{ 64 | 'content-type':"application/json", 65 | authorization:"bearer " + token['access_token'] 66 | }, 67 | /* timeout:2000 */ 68 | } 69 | 70 | options 71 | var data = await axiosClient(options).catch((error) => { 72 | return Promise.reject(error) 73 | }) 74 | 75 | return {userPrincipalName:data?.value, appId} || data 76 | 77 | } 78 | 79 | 80 | 81 | async function graphListS (token, operation, skiptoken, responseCollector) { 82 | 83 | var options = { 84 | responseType: 'json', 85 | "method": "get", 86 | url:`${token.resource}/beta/${operation}`, 87 | headers:{ 88 | 'content-type':"application/json", 89 | authorization:"bearer " + token['access_token'] 90 | } 91 | } 92 | 93 | if (skiptoken) { 94 | options.url = skiptoken 95 | } 96 | 97 | var data = await axiosClient(options).catch((error) => { 98 | return Promise.reject(error) 99 | }) 100 | 101 | 102 | if (data['@odata.nextLink']) { 103 | console.log('getting results:',data.value.length) 104 | data.value.forEach((item) => responseCollector.push(item)) 105 | console.log(data['@odata.nextLink']) 106 | await graphListS(token,operation,data['@odata.nextLink'],responseCollector) 107 | 108 | } 109 | else { 110 | return data.value.forEach((item) => responseCollector.push(item)) 111 | } 112 | 113 | } 114 | 115 | 116 | 117 | async function graphList (token, operation, skiptoken, responseCollector) { 118 | 119 | var options = { 120 | responseType: 'json', 121 | "method": "get", 122 | url:`${token.resource}/v1.0/${operation}`, 123 | headers:{ 124 | 'content-type':"application/json", 125 | authorization:"bearer " + token['access_token'] 126 | } 127 | } 128 | 129 | 130 | 131 | if (skiptoken) { 132 | options.url = skiptoken 133 | } 134 | 135 | var data = await axiosClient(options).catch((error) => { 136 | return Promise.reject(error) 137 | }) 138 | 139 | 140 | if (data['@odata.nextLink']) { 141 | data.value.forEach((item) => responseCollector.push(item)) 142 | console.log(data['@odata.nextLink']) 143 | await graphList(token,operation,data['@odata.nextLink'],responseCollector) 144 | 145 | } 146 | else { 147 | return data.value.forEach((item) => responseCollector.push(item)) 148 | } 149 | 150 | } 151 | 152 | 153 | async function batchThrottledSimple (burstCount, arrayOfObjects) { 154 | 155 | var promArra = [] 156 | var returnObject = [] 157 | let i = 0 158 | 159 | for await ({runContext} of arrayOfObjects) { 160 | i++ 161 | // console.log(i) 162 | 163 | var {fn,opts} = runContext 164 | 165 | if (i % burstCount == 0) { 166 | await waitT(1000) 167 | } 168 | 169 | promArra.push( 170 | fn(opts).catch((error) => { 171 | console.log('no match in graph', opts) 172 | //returnObject.push({error:resourceId}) 173 | }).then((data) => { 174 | if (data) { 175 | returnObject.push(data) 176 | } 177 | 178 | }) 179 | ) 180 | 181 | } 182 | 183 | await Promise.all(promArra) 184 | return returnObject 185 | 186 | } 187 | 188 | 189 | 190 | var waitT = require('util').promisify(setTimeout) 191 | 192 | async function batchThrottled (run, burstCount, arrayOfObjects, token) { 193 | 194 | var promArra = [] 195 | var returnObject = [] 196 | let i = 0 197 | 198 | for await (item of arrayOfObjects) { 199 | i++ 200 | console.log(i) 201 | 202 | if (i % burstCount == 0) { 203 | await waitT(1000) 204 | } 205 | 206 | promArra.push( 207 | run(item,token).catch((error) => { 208 | returnObject.push(item.error = error) 209 | }).then((data) => { 210 | console.log(data) 211 | returnObject.push(data) 212 | }) 213 | ) 214 | 215 | } 216 | 217 | await Promise.all(promArra) 218 | return returnObject 219 | 220 | } 221 | 222 | 223 | 224 | 225 | 226 | module.exports={graph, graphList, batchThrottled,graphOwner,graphListS, graphExtended} 227 | -------------------------------------------------------------------------------- /Client2.js: -------------------------------------------------------------------------------- 1 | var {graph, graphList, graphOwner, graphExtended} = require('./src/graphf') 2 | const fs = require('fs') 3 | const {argv} = require('yargs') 4 | 5 | 6 | module.exports={main} 7 | 8 | async function main (token) { 9 | 10 | 11 | 12 | var gRsponse2=[] 13 | var firstop = 'oauth2PermissionGrants' 14 | await graphList(token,firstop, undefined, gRsponse2).catch((error) => { 15 | console.log(error) 16 | }) 17 | fs.writeFileSync(`${firstop}.json`,JSON.stringify(gRsponse2)) 18 | 19 | var users = new Set(gRsponse2.map(user => user.principalId)) 20 | //console.log(users) 21 | 22 | var usersProm = [] 23 | var usersList = [] 24 | users.forEach((user) => { 25 | if(user !== null) { 26 | 27 | usersProm.push( 28 | graph(token,`users/${user}`).catch((error) => { 29 | console.log(error) 30 | }).then((user) => { 31 | usersList.push(user) 32 | }) 33 | ) 34 | 35 | } 36 | 37 | }) 38 | 39 | await Promise.all(usersProm) 40 | fs.writeFileSync('users.json',JSON.stringify(usersList)) 41 | 42 | 43 | 44 | //Apps begin here 45 | var gRsponse1=[] 46 | var firstop = 'applications' 47 | await graphList(token,firstop, undefined, gRsponse1).catch((error) => { 48 | console.log(error) 49 | }) 50 | 51 | fs.writeFileSync(`${firstop}-save.json`,JSON.stringify(gRsponse1)) 52 | 53 | 54 | let AppProxyApps 55 | if (argv.appProxyApps) { 56 | 57 | let proxArra = [] 58 | 59 | gRsponse1.filter( proxyApp => proxyApp?.web?.logoutUrl !== null).filter(s => s.web?.logoutUrl.toLowerCase().match('appproxy=logout') ) 60 | .forEach(prx => { 61 | 62 | 63 | 64 | proxArra.push( graphExtended(token,`applications/${prx.id}?$select=onPremisesPublishing,appId`) ) 65 | 66 | 67 | }) 68 | 69 | AppProxyApps = await Promise.all(proxArra) 70 | 71 | } 72 | 73 | 74 | 75 | 76 | 77 | var appArra =[] 78 | var appList = [] 79 | 80 | 81 | var burstCount = 40 82 | var i = 0 83 | for await (app of gRsponse1) { 84 | i++ 85 | console.log(i) 86 | if (i % burstCount == 0) { 87 | await waitT(1000) 88 | } 89 | console.log('checking', app.appId) 90 | appArra.push( 91 | graphOwner(token, `applications/${app.id}/owners`, app.appId).catch((error) => { 92 | console.log('error', error) 93 | }).then((data) => { 94 | if (data?.userPrincipalName?.length > 0) { 95 | 96 | if(data.appId == "dca4e297-f238-4e84-93f1-f443ace5adc9") { 97 | console.log('mat') 98 | } 99 | 100 | console.log('pushing to', data.appId) 101 | appList.push({ 102 | appId: data.appId, 103 | userPrincipalName: data.userPrincipalName.map((item) => { 104 | return item.userPrincipalName 105 | }) 106 | }) 107 | } 108 | 109 | }) 110 | ) 111 | 112 | } 113 | 114 | await Promise.all(appArra) 115 | //Apps end here 116 | 117 | fs.writeFileSync(`${firstop}.json`,JSON.stringify(appList)) 118 | 119 | 120 | 121 | var gRsponse=[] 122 | var firstop = 'servicePrincipals' 123 | await graphList(token,firstop, undefined, gRsponse).catch((error) => { 124 | console.log(error) 125 | }) 126 | 127 | // Map owners to SPN here 128 | 129 | gRsponse.map((item) => { 130 | item.owners = appList.find((app) => { 131 | item.owners = [] 132 | if (app.appId == item.appId) { 133 | return item.owners = app.userPrincipalName 134 | } 135 | }) 136 | 137 | if (item.owners?.appId) { 138 | delete item.owners.appId 139 | } 140 | //console.log(item.owners) 141 | 142 | }) 143 | 144 | gRsponse.map((item) => { 145 | if (!item.owners) { 146 | item.owners=[] 147 | } 148 | }) 149 | 150 | 151 | if (argv.appProxyApps) { 152 | 153 | gRsponse.map(app => { 154 | app.onPremisesPublishing = AppProxyApps.find( s => s.appId == app.appId)?.onPremisesPublishing || null 155 | 156 | if (app.onPremisesPublishing) { 157 | console.log('sd') 158 | } 159 | 160 | }) 161 | } 162 | 163 | 164 | 165 | 166 | let arr4 = [] 167 | let samlApps = [] 168 | // Get saml Apps 169 | i = 0 170 | let SAMLCheck = gRsponse.filter(app => app?.preferredSingleSignOnMode == "saml" ) 171 | 172 | for await (samlApp of SAMLCheck) { 173 | 174 | i++ 175 | console.log(i) 176 | if (i % 5 == 0) { 177 | await waitT(1000) 178 | } 179 | 180 | 181 | arr4.push( graphExtended(token,`serviceprincipals/${samlApp.id}?$select=notificationEmailAddresses,keyCredentials,id`).then(data => { 182 | console.log(data) 183 | samlApps.push(data) 184 | }).catch(error => console.log(error?.response?.data)) ) 185 | 186 | 187 | 188 | } 189 | 190 | await Promise.all(arr4) 191 | 192 | 193 | gRsponse.map(app => { 194 | app.samlSettings = samlApps.find( s => s.id == app.id) || null 195 | 196 | if (app.samlSettings) { 197 | console.log('sd') 198 | } 199 | 200 | }) 201 | 202 | fs.writeFileSync(`${firstop}.json`,JSON.stringify(gRsponse)) 203 | 204 | var promArra = [] 205 | var spnList = [] 206 | 207 | function waitT (ms) { 208 | return new Promise((resolve) => { 209 | setTimeout(() => { 210 | resolve('waited') 211 | }, ms); 212 | } 213 | 214 | ) 215 | 216 | } 217 | 218 | var burstCount = 50 219 | var i = 0 220 | for await (spn of gRsponse) { 221 | i ++ 222 | console.log(i) 223 | if (i % burstCount == 0) { 224 | await waitT(1000) 225 | } 226 | promArra.push( 227 | graph(token,`servicePrincipals/${spn.id}/appRoleAssignedTo`).catch((error) => { 228 | console.log('error',error) 229 | }).then((data) => { 230 | 231 | if (data?.length > 0) { 232 | console.log(data) 233 | data.forEach((item) => spnList.push(item)) 234 | } 235 | }) 236 | ) 237 | 238 | } 239 | 240 | /* gRsponse.forEach((spn) => { 241 | 242 | promArra.push( 243 | graph(token,`servicePrincipals/${spn.id}/appRoleAssignedTo`).catch((error) => { 244 | console.log('error',error) 245 | }).then((data) => { 246 | 247 | if (data?.length > 0) { 248 | data.forEach((item) => spnList.push(item)) 249 | } 250 | }) 251 | ) 252 | }) */ 253 | 254 | await Promise.all(promArra) 255 | fs.writeFileSync(`roles.json`,JSON.stringify(spnList)) 256 | 257 | 258 | } 259 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # This tool is not maintained anymore, please consider using https://github.com/jsa2/AADAppAudit 4 | 5 | - [License](#license) 6 | - [Consent and Azure AD application Analytics solution](#consent-and-azure-ad-application-analytics-solution) 7 | - [Illicit Consent Grant](#illicit-consent-grant) 8 | - [Use cases](#use-cases) 9 | - [Intentional gaps](#intentional-gaps) 10 | - [Prerequisites](#prerequisites) 11 | - [About the generated KQL](#about-the-generated-kql) 12 | - [Running the tool](#running-the-tool) 13 | - [After initial run with main.js](#after-initial-run-with-mainjs) 14 | - [without autoRun](#without-autorun) 15 | - [Checking results again](#checking-results-again) 16 | - [Check App Proxy Apps](#check-app-proxy-apps) 17 | - [Check Saml App expiration](#check-saml-app-expiration) 18 | - [Use existing storage account](#use-existing-storage-account) 19 | - [Use with different context after setting up the needed storage account](#use-with-different-context-after-setting-up-the-needed-storage-account) 20 | - [Use existing account with IP limitations](#use-existing-account-with-ip-limitations) 21 | - [Regenerate SAS tokens for existing data](#regenerate-sas-tokens-for-existing-data) 22 | - [Alternative Sorting (per API)](#alternative-sorting-per-api) 23 | - [With SignInLogs (only shows apps that have sign-in data - requires workspace with signins)](#with-signinlogs-only-shows-apps-that-have-sign-in-data---requires-workspace-with-signins) 24 | - [With SignIn and auditLogs (only shows apps that have sign-in data - requires workspace with signins)](#with-signin-and-auditlogs-only-shows-apps-that-have-sign-in-data---requires-workspace-with-signins) 25 | - [Check permissionless use of possibly malicious multitenant ServicePrincipals](#check-permissionless-use-of-possibly-malicious-multitenant-serviceprincipals) 26 | - [Check for plaintext redirectURI's](#check-for-plaintext-redirecturis) 27 | - [Update log](#update-log) 28 | - [Known issues](#known-issues) 29 | - [Continous Access Evaluation](#continous-access-evaluation) 30 | - [Multiple tenants](#multiple-tenants) 31 | 32 | ## License 33 | 34 | [READ HERE](https://github.com/jsa2/CloudShellAadApps/blob/public/LICENSE) 35 | 36 | --- 37 | 38 | ⚠ Only use this tool if you know what you are doing and have reviewed the code 39 | 40 | ⚠ Always test the tool first in test environments, with non-sensitive data 41 | 42 | --- 43 | As the licenses says, 0% Liability 0% Warranty 44 | 45 | --- 46 | 47 | # This tool is not maintained anymore, please consider using https://github.com/jsa2/AADAppAudit 48 | 49 | ## Consent and Azure AD application Analytics solution 50 | 51 | # This tool is not maintained anymore, please consider using https://github.com/jsa2/AADAppAudit 52 | Azure AD consent framework analysis is important step to strengthen security posture in every organization that is using Azure Active Directory. This tool was initially developed to analyze possible illicit consent grant attacks & in help of analyzing Azure AD consent grant framework but has been developed further since to provide answers to the most typical security related questions around Azure AD integrated apps and permissions. 53 | 54 | ### Illicit Consent Grant 55 | 56 | During Covid-19 there has been huge increase in consent phishing emails where the idea is to abuse OAuth request links to trick recipients into granting attacker owned apps permission to access sensitive data. Consent grant is perfect tool to create backdoor, and MFA bypasses in the victim’s environment. After the illicit application has been granted consent, it has account-level access to data without the need for an organizational account. 57 | 58 | There are two scenarios for attacker to pursue targeting individual users: 59 | - Individual consent grants for non-admin permissions 60 | - Targeting admins for requiring permissions that only admins can grant 61 | 62 | Both scenarios allows data exfiltration, while the latter also offers perfect backdooring entry (App permissions for multi-tenant app). More information about the attack and analysis can be found from the following sources: 63 | - [Azure AD Attack & Defense Playbook](https://github.com/Cloud-Architekt/AzureAD-Attack-Defense) 64 | - [MITRE ATT&ACK - Steal Application Token](https://attack.mitre.org/techniques/T1528/) 65 | - [Detect & Remediate ](https://docs.microsoft.com/en-us/microsoft-365/security/office-365-security/detect-and-remediate-illicit-consent-grants?view=o365-worldwide) 66 | 67 | 68 | 69 | ## Use cases 70 | 71 | Use Case Name | Notes 72 | -|- 73 | ✅ Inventory of apps and permissions | All Azure AD apps and the apps registered permissions including Workload Identities 74 | ✅ Detect applications that share app and user permissions / scopes | By default Apps that have delegated permissions should not include Application permissions 75 | ✅ Detect password use on applications, and expiring/expired passwords | [Two types of credentials available](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#authentication-two-options): password-based or certificate-based authentication 76 | ✅ Detect AppType (Managed, Multi, single etc) | [Tenancy in Azure AD](https://docs.microsoft.com/en-us/azure/active-directory/develop/single-and-multi-tenant-apps) 77 | ✅ Review replyURLs | Verify are there any malicious [reply URLs](https://docs.microsoft.com/en-us/azure/active-directory/develop/reply-url) used in the apps 78 | ✅ Detect recent sign-ins | Get insights on how apps are used in the organization (this API is setting not enabled by default) 79 | ✅ Detect servicePrincipals in admin roles | It is in most cases recommended to use API permissions instead of AAD roles 80 | ✅ Detect dangling redirect_uri | If the app service is deleted, but redirect_uri is not deleted from the Azure AD app registration, attacker could register the App Service instance for malicious intent. 81 | ✅ User assignment | review if app has user assigment enabled 82 | ✅ HasPublicClient | review if app allows public client flows (non redirect uri based flows) 83 | ✅ WarningAppPrivs | is multitenant application with app permissions
this permission type is very potent for the attacker, because the app owner does not needed signed-in user content (delegation) in the victim tenant to access services granted to the app 84 | ✅ expiration | Detect SAML certificate and client credential 85 | ✅ Owners | review app owners 86 | ✅ audit app proxy applications | this one requires further permissions:
``microsoft.directory/connectors/allProperties/read`` - Read all properties of application proxy connectors 87 | ✅Check permissionless use of possibly malicious multitenant ServicePrincipals |[``Check permissionless use of possibly malicious multitenant ServicePrincipals``](#check-permissionless-use-of-possibly-malicious-multitenant-serviceprincipals) 88 | ✅ Check for plaintext redirectURI's | [``Check for plaintext redirectURI's``](#check-for-plaintext-redirecturis) 89 | 90 | ![./Pictures/Results-2-1.jpg](./Pictures/Results-2-1.jpg) 91 | 92 | ## Intentional gaps 93 | ⚠️ There are also occurences where required ResourceAccess are configured on the apps, but no permissions are granted to users via user consent, or admin consent. These occurences do not show on the report. While they also have potential for abuse, they have no active permissions granted. (this does not concern app permissions) 94 | 95 | ## Prerequisites 96 | 97 | Requirement | description | 98 | -|- 99 | ✅ Access to Azure Cloud Shell Bash | Uses pre-existing software on Azure CLI, Node etc 100 | ✅ Permissions to Azure subscription to create needed resources | Tool creates a storage account and a resource group. Possible also to use existing storage account. In both scenarios tool generates short lived read-only shared access links (SAS) for the ``externalData()`` -[operator](https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/externaldata-operator?pivots=azuredataexplorer#examples) 101 | ✅ User is Azure AD member |Cloud-only preferred with read-only Azure AD permissions. More permissions are needed if sign-in events are included 102 | ✅ Existing Log Analytics Workspace | This is where you paste the output from this tool 103 | 104 | ### About the generated KQL 105 | - The query is valid for 10 minutes, as SAS tokens are only generated for 10 minutes 106 | - If you want to regenerate the query follow these [steps](#regenerate-sas-tokens-for-existing-data) 107 | 108 | ## Running the tool 109 | 110 | - Log in to Azure Cloud Shell (**BASH** ) and paste following line to the shell 111 | ```bash 112 | curl -o- https://raw.githubusercontent.com/jsa2/CloudShellAadApps/public/remote.sh | bash 113 | ``` 114 | Once complete you should see following screen, which includes that you can paste to Log Analytics space 115 | ![image](https://user-images.githubusercontent.com/58001986/147247112-1262e8a5-7040-4e8d-8c49-afe94c066493.png) 116 | 117 | 118 | ## After initial run with main.js 119 | - nvm use 14, is only needed in cloud shell 120 | - If you are having problems with the tool start by ensuring, that existing installation of the tool does on exist in cloudShell: ``admin@Azure:~$ rm CloudShellAadApps/ -r -f`` 121 | 122 | 123 | ### without autoRun 124 | ```bash 125 | curl -o- https://raw.githubusercontent.com/jsa2/CloudShellAadApps/public/remoteInit.sh | bash 126 | ``` 127 | 128 | --- 129 | 130 | **Each of this step requires that you then copy the query kql/runtime.kql and paste it in log analytics** 131 | 132 | As pointed out earlier, these can be run, once you've run the initial run with main.js 133 | 134 | --- 135 | 136 | ### Checking results again 137 | ```bash 138 | cd Cloud CloudShellAadApps 139 | nvm use 14; node main.js 140 | ``` 141 | 142 | ### Check App Proxy Apps 143 | - Requires further permissions like the sign-in logs query (while all other checks here work with reader) 144 | ```bash 145 | cd Cloud CloudShellAadApps 146 | node main --appProxyApps 147 | nvm use 14; node schemaForAppProxyApps.js --appProxyApps 148 | ``` 149 | 150 | ![image](https://user-images.githubusercontent.com/58001986/169269900-b59073c6-c50b-4f2d-8b70-f47c51895f24.png) 151 | 152 | 153 | 154 | 155 | ### Check Saml App expiration 156 | 157 | Checks for email addresses and time till SAML certificate expires. 158 | 159 | ```bash 160 | cd Cloud CloudShellAadApps 161 | nvm use 14; node schemaForSaml.js 162 | ``` 163 | 164 | ![image](https://user-images.githubusercontent.com/58001986/169268535-067dee25-45c3-4697-a600-0143e5fcc472.png) 165 | 166 | ### Use existing storage account 167 | ```bash 168 | rg=queryStorage-23428 169 | storageAcc=storagehowrjcehuw 170 | git clone https://github.com/jsa2/CloudShellAadApps 171 | cd CloudShellAadApps 172 | az storage account show-connection-string -g $rg -n $storageAcc -o json > src/config.json 173 | npm install 174 | nvm use 14; node main.js 175 | ``` 176 | 177 | 178 | ### Use with different context after setting up the needed storage account 179 | ``` 180 | az account clear 181 | az login --use-device-code --allow-no-subscriptions 182 | ``` 183 | 184 | ### Use existing account with IP limitations 185 | 186 | ```bash 187 | az account set --name "scan" 188 | 189 | storageAcc=dogs 190 | rg=queryStorage-29991 191 | location=westeurope 192 | net=$(curl a.dewi.red) 193 | 194 | az storage account show-connection-string -g $rg -n $storageAcc -o json > src/config.json 195 | 196 | #Add cloud shell IP 197 | az storage account network-rule add -g $rg --account-name $storageAcc --ip-address $net 198 | 199 | # Remove the storage account rule for Cloud shell 200 | az storage account network-rule remove -g $rg --account-name $storageAcc --ip-address $net 201 | ``` 202 | 203 | ### Regenerate SAS tokens for existing data 204 | ```bash 205 | cd Cloud CloudShellAadApps 206 | nvm use 14; node schemaForExternalData.js 207 | code kql/runtime.kql 208 | ``` 209 | 210 | ### Alternative Sorting (per API) 211 | ```bash 212 | cd Cloud CloudShellAadApps 213 | nvm use 14; node schemaForAPIdriven.js 214 | code kql/runtime.kql 215 | ``` 216 | ![image](https://user-images.githubusercontent.com/58001986/158386483-b2c3b7eb-b37e-46c2-b72c-7d18c51ae361.png) 217 | 218 | ### With SignInLogs (only shows apps that have sign-in data - requires workspace with signins) 219 | Requires following sources (AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs, AADManagedIdentitySignInLogs, SigninLogs) 220 | ```bash 221 | cd Cloud CloudShellAadApps 222 | nvm use 14; node schemaForExternalDataLAsignins.js 223 | code kql/runtime.kql 224 | ``` 225 | 226 | ### With SignIn and auditLogs (only shows apps that have sign-in data - requires workspace with signins) 227 | ```bash 228 | cd Cloud CloudShellAadApps 229 | nvm use 14; node schemaForExternalDataLAsignisAndAudit.js 230 | code kql/runtime.kql 231 | ``` 232 | 233 | ### Check permissionless use of possibly malicious multitenant ServicePrincipals 234 | - Checks if possibly malicious multitenant SPN's with no app permissions present are using client credentials based flows against apps in your tenant 235 | ```bash 236 | cd Cloud CloudShellAadApps 237 | nvm use 14; node schemaForMaliciousMultiTenant.js 238 | code kql/runtime.kql 239 | ``` 240 | 241 | ### Check for plaintext redirectURI's 242 | - append this part to runtime.kql after node schemaForExternalData.js is run 243 | ``` 244 | nvm use 14; node schemaForExternalData.js 245 | code kql/runtime.kql; 246 | final 247 | | mv-apply url = set_replyUrls to typeof(string) on ( 248 | where (url contains "http://" and url !contains "http://localhost") 249 | ) 250 | 251 | ``` 252 | 253 | 254 | ![image](https://user-images.githubusercontent.com/58001986/169637591-5decbb5e-8e24-4078-a3c5-01411bfb9811.png) 255 | 256 | Example of insecure non localhost redirectURI: 257 | 258 | ![image](https://user-images.githubusercontent.com/58001986/169637895-edb48f0b-3e62-4001-b654-a8a341fdc5ac.png) 259 | 260 | 261 | ## Update log 262 | 263 | - 19.05.2022 SAML App Expiration checking 264 | - 15.05.2022 added AppProxy auditing 265 | - 15.03.2022 added alternative sorting (per API) and with LA 266 | 267 | **Previously** 268 | 269 | ⚠️ Dangling redirect URI 270 | 271 | - Malicious use case: If the app service is deleted, but redirect_uri is not deleted from the Azure AD app registration, attacker could register the App Service instance for malicious intent. After registering the App Service instance Attacker would then redirect user sessions authorization codes/tokens to attacker controlled service. 272 | 273 | ⚠️ Multi-tenant app with app permissions. 274 | 275 | ⚠️ Device Code Flow enabled for app that has redirect URI's 276 | - This enables the attacker to phish for access tokens without having control of the redirect URI as the attacker is able to set up an page asking for the device code. 277 | - Users are likely less susceptible to device code based phishing * compared to pure SSO based phishing with seamless redirect to attacker controlled service) - Nonetheless public client on a redirect enabled application presents a valid attack vector. 278 | 279 | 280 | 281 | ## Known issues 282 | ### Continous Access Evaluation 283 | Azure CLI is unable to obtain new access tokens for sessions, that rely on IP restrictions and are targeteted by [strict enforcement](https://docs.microsoft.com/en-us/azure/active-directory/conditional-access/concept-continuous-access-evaluation#ip-address-variation) 284 | ``az account get-access-token --resource=https://graph.microsoft.com --query accessToken --output json`` 285 | ![image](https://user-images.githubusercontent.com/58001986/146808098-035dd7a9-1314-41fe-aa36-471988da634d.png) 286 | 287 | ### Multiple tenants 288 | If the identity you are using doesn't have Azure subscription access or has access to multiple tenants use 289 | ```bash 290 | az login --allow-no-subscriptions ## (no access to Azure subscriptions) 291 | ``` 292 | 293 | ```bash 294 | az login --tenant ## (If user has identity in multiple tenants) 295 | ``` 296 | ![./Pictures/Login-3.JPG](./Pictures/Login-3.JPG) 297 | --------------------------------------------------------------------------------