├── 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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
255 |
256 | Example of insecure non localhost redirectURI:
257 |
258 | 
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 | 
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 | 
297 |
--------------------------------------------------------------------------------