Skip to content

Commit da332fa

Browse files
authored
fix: Dev to hotfix (#2148)
2 parents 3529092 + 98d8244 commit da332fa

92 files changed

Lines changed: 3295 additions & 949 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/validate-json.mjs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { readFile, readdir, writeFile, appendFile } from "node:fs/promises";
2+
import path from "node:path";
3+
4+
// Usage: node validate-json.mjs [--strip <prefix>] <dir> [dir...]
5+
// --strip removes a leading path prefix from reported filenames, so a PR checked
6+
// out into a subdirectory still reports repo-relative paths.
7+
const argv = process.argv.slice(2);
8+
let strip = "";
9+
const roots = [];
10+
for (let i = 0; i < argv.length; i++) {
11+
if (argv[i] === "--strip") {
12+
strip = argv[++i] ?? "";
13+
} else {
14+
roots.push(argv[i]);
15+
}
16+
}
17+
18+
async function collect(dir) {
19+
let entries;
20+
try {
21+
entries = await readdir(dir, { withFileTypes: true });
22+
} catch (error) {
23+
if (error.code === "ENOENT") return [];
24+
throw error;
25+
}
26+
const files = [];
27+
for (const entry of entries) {
28+
const full = path.join(dir, entry.name);
29+
if (entry.isDirectory()) {
30+
if (entry.name === "node_modules") continue;
31+
files.push(...(await collect(full)));
32+
} else if (entry.name.endsWith(".json")) {
33+
files.push(full);
34+
}
35+
}
36+
return files;
37+
}
38+
39+
const report = (file) => {
40+
const normalised = file.split(path.sep).join("/");
41+
return strip && normalised.startsWith(strip) ? normalised.slice(strip.length) : normalised;
42+
};
43+
44+
const failures = [];
45+
let checked = 0;
46+
47+
for (const root of roots) {
48+
for (const file of await collect(root)) {
49+
checked++;
50+
// Strip a leading UTF-8 BOM: it's tolerated by PowerShell's ConvertFrom-Json
51+
// (how the API loads these files) but rejected by Node's JSON.parse.
52+
const contents = (await readFile(file, "utf8")).replace(/^\uFEFF/, "");
53+
try {
54+
JSON.parse(contents);
55+
} catch (error) {
56+
failures.push({ file: report(file), message: error.message.replace(/\r?\n/g, " ") });
57+
}
58+
}
59+
}
60+
61+
for (const { file, message } of failures) {
62+
// Annotate the PR diff via a GitHub Actions error command.
63+
console.log(`::error file=${file}::${message}`);
64+
}
65+
console.log(`Checked ${checked} JSON file(s), ${failures.length} invalid.`);
66+
67+
// Hand the results to the workflow so it can comment on the PR.
68+
if (process.env.GITHUB_OUTPUT) {
69+
await appendFile(process.env.GITHUB_OUTPUT, `invalid_count=${failures.length}\n`);
70+
}
71+
await writeFile("json-validation-results.json", JSON.stringify(failures, null, 2));
72+
73+
process.exit(failures.length > 0 ? 1 : 0);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
---
2+
name: Validate JSON
3+
on:
4+
# pull_request_target (not pull_request) so the token can comment on fork PRs.
5+
# The PR's own code is never executed: it is checked out into ./pr as data only,
6+
# and parsed by the validator script from the trusted base checkout.
7+
pull_request_target:
8+
types: [opened, synchronize, reopened]
9+
branches:
10+
- main
11+
- dev
12+
paths:
13+
- "Config/**/*.json"
14+
- "Tools/**/*.json"
15+
- "AddMSPApp/**/*.json"
16+
- ".github/workflows/Validate_JSON.yml"
17+
- ".github/scripts/validate-json.mjs"
18+
push:
19+
branches:
20+
- dev
21+
paths:
22+
- "Config/**/*.json"
23+
- "Tools/**/*.json"
24+
- "AddMSPApp/**/*.json"
25+
- ".github/workflows/Validate_JSON.yml"
26+
- ".github/scripts/validate-json.mjs"
27+
concurrency:
28+
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.ref }}
29+
cancel-in-progress: true
30+
permissions:
31+
contents: read
32+
pull-requests: write
33+
jobs:
34+
validate:
35+
name: Parse JSON in Config, Tools and AddMSPApp
36+
runs-on: ubuntu-latest
37+
steps:
38+
- name: Checkout base (trusted validator script)
39+
uses: actions/checkout@v6
40+
41+
- name: Checkout PR head (untrusted, data only)
42+
if: github.event_name == 'pull_request_target'
43+
uses: actions/checkout@v6
44+
with:
45+
repository: ${{ github.event.pull_request.head.repo.full_name }}
46+
ref: ${{ github.event.pull_request.head.sha }}
47+
path: pr
48+
persist-credentials: false
49+
50+
- name: Validate JSON files
51+
id: validate
52+
continue-on-error: true
53+
env:
54+
ROOT: ${{ github.event_name == 'pull_request_target' && 'pr/' || '' }}
55+
run: >-
56+
node .github/scripts/validate-json.mjs --strip "$ROOT"
57+
"${ROOT}Config" "${ROOT}Tools" "${ROOT}AddMSPApp"
58+
59+
- name: Comment on PR
60+
if: github.event_name == 'pull_request_target'
61+
uses: actions/github-script@v9
62+
with:
63+
github-token: ${{ secrets.GITHUB_TOKEN }}
64+
script: |
65+
const fs = require('fs');
66+
const marker = '<!-- validate-json -->';
67+
const resultsFile = 'json-validation-results.json';
68+
if (!fs.existsSync(resultsFile)) {
69+
// The validator crashed before reporting; let its own error stand.
70+
core.warning('No validation results found — skipping PR comment.');
71+
return;
72+
}
73+
const failures = JSON.parse(fs.readFileSync(resultsFile, 'utf8'));
74+
75+
// Find a previous comment from this workflow so we update instead of piling up.
76+
const { data: comments } = await github.rest.issues.listComments({
77+
...context.repo,
78+
issue_number: context.issue.number,
79+
per_page: 100,
80+
});
81+
const existing = comments.find(
82+
(c) => c.user.type === 'Bot' && c.body.includes(marker)
83+
);
84+
85+
let body;
86+
if (failures.length > 0) {
87+
const list = failures
88+
.map(({ file, message }) => `- \`${file}\`\n > ${message}`)
89+
.join('\n');
90+
body =
91+
`${marker}\n### ⚠️ Invalid JSON detected\n\n` +
92+
`${failures.length} JSON file(s) in this PR could not be parsed. ` +
93+
`These files are loaded directly by CIPP, so a syntax error here breaks the app at runtime.\n\n` +
94+
`${list}\n\n` +
95+
`Please fix the syntax and push again — this comment will update automatically.`;
96+
} else if (existing) {
97+
body = `${marker}\n### ✅ JSON is valid\n\nAll JSON files in \`Config\`, \`Tools\` and \`AddMSPApp\` parse correctly. Thanks for fixing it!`;
98+
} else {
99+
// Nothing was ever broken — stay quiet.
100+
return;
101+
}
102+
103+
if (existing) {
104+
await github.rest.issues.updateComment({
105+
...context.repo,
106+
comment_id: existing.id,
107+
body,
108+
});
109+
} else {
110+
await github.rest.issues.createComment({
111+
...context.repo,
112+
issue_number: context.issue.number,
113+
body,
114+
});
115+
}
116+
117+
- name: Fail if any JSON is invalid
118+
if: steps.validate.outputs.invalid_count != '0'
119+
run: |
120+
echo "::error::${{ steps.validate.outputs.invalid_count }} invalid JSON file(s). See annotations above."
121+
exit 1

Config/CIPPDBCacheTypes.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,11 @@
294294
"friendlyName": "SharePoint Site Usage",
295295
"description": "SharePoint site usage statistics"
296296
},
297+
{
298+
"type": "SiteActivity",
299+
"friendlyName": "Site Activity",
300+
"description": "Per-site activity with siteType classification (SharePoint, SharePointAndTeams, OneDrive) and separate workload activity columns"
301+
},
297302
{
298303
"type": "SharePointSharingLinks",
299304
"friendlyName": "SharePoint & OneDrive Sharing Links",

Config/standards.json

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,10 +1459,23 @@
14591459
"cat": "Entra (AAD) Standards",
14601460
"tag": ["CISA (MS.AAD.21.1v1)", "SMB1001 (2.8)"],
14611461
"appliesToTest": ["SMB1001_2_8", "ZTNA21868"],
1462-
"helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc",
1463-
"docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc",
1464-
"executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments.",
1465-
"addedComponent": [],
1462+
"helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc. Optionally allows members of a specific security group to keep creating groups (GroupCreationAllowedGroupId).",
1463+
"docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc. Optionally, a security group can be named whose members remain allowed to create groups; the group is resolved by display name in each tenant and can be created automatically when it does not exist. When no group is named, the existing GroupCreationAllowedGroupId value in the tenant is left untouched.",
1464+
"executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments. An approved group of designated users can optionally retain the ability to create groups.",
1465+
"addedComponent": [
1466+
{
1467+
"type": "textField",
1468+
"name": "standards.DisableM365GroupUsers.AllowedGroupName",
1469+
"label": "Optional: name of the group whose members may still create M365 groups",
1470+
"required": false
1471+
},
1472+
{
1473+
"type": "switch",
1474+
"name": "standards.DisableM365GroupUsers.CreateGroup",
1475+
"label": "Create the allowed group if it does not exist",
1476+
"required": false
1477+
}
1478+
],
14661479
"label": "Disable M365 Group creation by users",
14671480
"impact": "Low Impact",
14681481
"impactColour": "info",
@@ -3219,6 +3232,21 @@
32193232
"powershellEquivalent": "Get-Mailbox & Update-MgUser",
32203233
"recommendedBy": ["CIS", "CIPP"]
32213234
},
3235+
{
3236+
"name": "standards.TeamsDisableResourceAccounts",
3237+
"cat": "Teams Standards",
3238+
"tag": ["NIST CSF 2.0 (PR.AA-01)"],
3239+
"helpText": "Blocks sign-in for all Teams resource accounts used by Auto Attendants and Call Queues. Microsoft's guidance is to block sign-in for resource accounts as they do not require an interactive login to function.",
3240+
"docsDescription": "Teams resource accounts (the accounts backing Auto Attendants and Call Queues) do not require interactive sign-in to function. If sign-in is enabled and the password is reset, the account can be logged into directly, which presents a security risk. Microsoft's guidance is to block sign-in for these accounts. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.",
3241+
"executiveText": "Prevents direct login to the service accounts that power phone system features like Auto Attendants and Call Queues. These accounts work without anyone signing into them, so blocking sign-in removes an unnecessary attack surface while keeping the phone system fully functional.",
3242+
"addedComponent": [],
3243+
"label": "Block sign-in for Teams resource accounts",
3244+
"impact": "Medium Impact",
3245+
"impactColour": "warning",
3246+
"addedDate": "2026-07-17",
3247+
"powershellEquivalent": "Get-CsOnlineApplicationInstance & Update-MgUser",
3248+
"recommendedBy": ["Microsoft", "CIPP"]
3249+
},
32223250
{
32233251
"name": "standards.DisableResourceMailbox",
32243252
"cat": "Exchange Standards",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
function Push-ExecSharePointTemplateDeploy {
2+
<#
3+
.FUNCTIONALITY
4+
Entrypoint
5+
.DESCRIPTION
6+
Queue worker that deploys a SharePoint provisioning template to a single tenant.
7+
Queued per tenant by Invoke-ExecSharePointTemplate (Action=Deploy). Progress is
8+
written to the shared CacheAsyncDeployments status rows so the frontend can poll
9+
it live via Action=DeployStatus.
10+
#>
11+
param($Item)
12+
13+
try {
14+
$Item = $Item | ConvertTo-Json -Depth 10 | ConvertFrom-Json
15+
$TemplateId = $Item.TemplateId
16+
$DeploymentId = $Item.DeploymentId
17+
if (-not $TemplateId) {
18+
Write-LogMessage -message 'No SharePoint template specified' -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
19+
return $false
20+
}
21+
22+
$Table = Get-CIPPTable -TableName 'templates'
23+
$Template = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'SharePointTemplate' and RowKey eq '$TemplateId'"
24+
if (-not $Template) {
25+
Write-LogMessage -message "SharePoint template $TemplateId not found" -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
26+
if ($DeploymentId) {
27+
Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $Item.Tenant -Status 'failed' -Logs "Template $TemplateId not found"
28+
}
29+
return $false
30+
}
31+
32+
if ($DeploymentId) {
33+
Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $Item.Tenant -Status 'running'
34+
}
35+
36+
$TemplateData = $Template.JSON | ConvertFrom-Json
37+
$Results = Invoke-CIPPSharePointTemplateDeploy -TemplateData $TemplateData -SiteOwner $Item.SiteOwner -TenantFilter $Item.Tenant -DeploymentId $DeploymentId
38+
foreach ($Result in $Results) {
39+
Write-Information $Result
40+
}
41+
42+
# Overall verdict: failed when any step failed, otherwise succeeded.
43+
if ($DeploymentId) {
44+
$Row = Get-CIPPAsyncDeployment -JobId $DeploymentId | Where-Object { $_.Name -eq $Item.Tenant }
45+
$FinalStatus = 'succeeded'
46+
if (@($Row.Steps | Where-Object { $_.Status -eq 'failed' }).Count -gt 0) { $FinalStatus = 'failed' }
47+
Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $Item.Tenant -Status $FinalStatus -Logs ($Results -join "`n")
48+
}
49+
return $true
50+
} catch {
51+
Write-LogMessage -message "Error deploying SharePoint template to tenant $($Item.Tenant) - $($_.Exception.Message)" -tenant $Item.Tenant -API 'Deploy SharePoint Template' -sev Error
52+
if ($Item.DeploymentId) {
53+
Set-CIPPAsyncDeploymentStatus -JobId $Item.DeploymentId -Name $Item.Tenant -Status 'failed' -Logs $_.Exception.Message
54+
}
55+
Write-Error $_.Exception.Message
56+
}
57+
}

Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ function Get-CIPPAlertInactiveLicensedUsers {
4747
$GraphRequest = New-GraphGetRequest -uri $Uri -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter |
4848
Where-Object { $null -ne $_.assignedLicenses -and $_.assignedLicenses.Count -gt 0 }
4949

50+
$LicenseOverview = @()
51+
try {
52+
$LicenseOverview = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'LicenseOverview')
53+
} catch {
54+
Write-Information "Could not get the license overview from the reporting DB, license names will fall back to SKU IDs: $($_.Exception.Message)"
55+
}
56+
5057
$AlertData = foreach ($user in $GraphRequest) {
5158
$lastInteractive = $user.signInActivity.lastSignInDateTime
5259
$lastNonInteractive = $user.signInActivity.lastNonInteractiveSignInDateTime
@@ -67,19 +74,28 @@ function Get-CIPPAlertInactiveLicensedUsers {
6774
if (-not $IncludeNeverSignedIn -and -not $lastSignIn) { continue }
6875
# Only process inactive users
6976
if ($isInactive) {
77+
$daysSinceSignIn = $null
7078
if (-not $lastSignIn) {
7179
$Message = 'User {0} has never signed in but still has a license assigned.' -f $user.UserPrincipalName
7280
} else {
7381
$daysSinceSignIn = [Math]::Round(((Get-Date) - [DateTime]$lastSignIn).TotalDays)
7482
$Message = 'User {0} has been inactive for {1} days but still has a license assigned. Last sign-in: {2}' -f $user.UserPrincipalName, $daysSinceSignIn, $lastSignIn
7583
}
84+
$LicenseNames = foreach ($SkuId in $user.assignedLicenses.skuId) {
85+
$Sku = $LicenseOverview | Where-Object { $_.skuId -eq $SkuId } | Select-Object -First 1
86+
if ($Sku.License) { $Sku.License } else { $SkuId }
87+
}
7688

7789
[PSCustomObject]@{
78-
UserPrincipalName = $user.UserPrincipalName
79-
Id = $user.id
80-
lastSignIn = $lastSignIn
81-
Message = $Message
82-
Tenant = $TenantFilter
90+
UserPrincipalName = $user.UserPrincipalName
91+
Id = $user.id
92+
lastSignIn = $lastSignIn
93+
DaysSinceLastSignIn = if ($null -ne $daysSinceSignIn) { $daysSinceSignIn } else { 'N/A' }
94+
AccountEnabled = $user.accountEnabled
95+
LicenseNames = $LicenseNames -join ', '
96+
SkuIds = @($user.assignedLicenses.skuId) -join ', '
97+
Message = $Message
98+
Tenant = $TenantFilter
8399
}
84100
}
85101
}

0 commit comments

Comments
 (0)