# Writing a pack

Create, check and publish a Maester Pack, with its manifest, tests, helpers and connector.

Source: https://maester.dev/docs/packs/writing-a-pack

# Writing a pack

:::tip[Guided setup]
To make a pack without writing the files by hand, use the guided setup at
[packs.maester.cloud/publish](https://packs.maester.cloud/publish). It copies the
[demo pack](https://github.com/maester365/maester-pack-demo) into a repository of yours, or starts from an empty one,
and fills in the manifest, the pack's Guide, its tests and its banner from a form. Then it releases the pack and
publishes it. It writes the same files this page describes, so you can change any of them later by hand.
:::

A pack is a folder with a `maester-pack.json`. Create one with `New-MtPack`:

```powershell
New-MtPack -Path ./maester-google-workspace -Id contoso.google-workspace -Code CNT -Connector
Test-MtPack ./maester-google-workspace
Invoke-MtTest -Path ./maester-google-workspace
```

`Invoke-MtTest -Path` runs the pack from its folder, with its helpers and connector, and reloads it on every call.
`Test-MtPack` runs every check `Install-MtPack` runs, without installing: use it in the pack's own CI.

```text
maester-google-workspace/
  maester-pack.json
  README.md
  tests/
    suite.json
    Test.MP.CNT.1001.ps1
    Test.MP.CNT.1001.md
  helpers/
    Get-CntWorkspaceSetting.ps1
  connector/
    Connect-CntGoogleWorkspace.ps1
```

To share the pack, push the folder to a GitHub repository owned by the publisher and create a release. A release made
[immutable](https://docs.github.com/repositories/releasing-projects-on-github/about-releases) starts its
cooldown when it is published; any other release starts it when each user first sees it. To have it listed where
people look for packs, see [Publishing your pack](#publishing-your-pack).

## The manifest

```json
{
  "$schema": "https://maester.dev/schemas/maester-pack-1.0.json",
  "Id": "contoso.google-workspace",
  "Name": "Google Workspace checks",
  "Description": "Checks the security settings of a Google Workspace tenant.",
  "Version": "1.2.0",
  "Publisher": "contoso",
  "License": "MIT",
  "RequiresMaester": ">=3.0",
  "Code": "CNT",
  "Suites": ["tests"],
  "Helpers": ["helpers"],
  "Connectors": [
    {
      "Service": "CNT.GoogleWorkspace",
      "DisplayName": "Google Workspace",
      "File": "connector/Connect-CntGoogleWorkspace.ps1",
      "Connect": "Connect-CntGoogleWorkspace",
      "Test": "Test-CntGoogleWorkspace",
      "Disconnect": "Disconnect-CntGoogleWorkspace"
    }
  ],
  "Uses": {
    "Services": ["CNT.GoogleWorkspace"],
    "GraphScopes": [],
    "TenantWrites": [],
    "Network": ["admin.googleapis.com", "oauth2.googleapis.com"],
    "Modules": [ { "Name": "GoogleWorkspaceTools", "Version": "1.4.2" } ]
  }
}
```

| Key | Content |
| --- | --- |
| `$schema` | `https://maester.dev/schemas/maester-pack-1.0.json`, so editors such as VS Code check the manifest as you type. Maester checks it again when the pack is installed and loaded. |
| `Id` | `<publisher>.<name>` in lower case. The publisher is the GitHub user or organisation that owns the repository; a pack whose publisher is not the repository owner is refused. |
| `Guid` | Required. A GUID that identifies the pack for good, such as `d1e2db80-6c27-4cb6-9389-2edf0a898d23`; `New-MtPack` makes one. Keep it, and the code, for every version of the pack: `Update-MtPack` refuses a release whose Guid or code changed. A pack with a new code is a new pack, with a new Guid. |
| `Version` | A semantic version, such as `1.2.0`. |
| `RequiresMaester` | The Maester versions the pack works with, such as `>=3.0`. Add an upper bound, such as `>=3.0 <4.0`, only if you know a later version breaks the pack. A pack that needs a newer Maester reports `RequiresNewerMaester`. `New-MtPack` fills in the version you made the pack with. |
| `Code` | The start of every test ID of the pack: `CNT` gives `MP.CNT.1001`. Two to ten letters or digits, starting with a letter. A publisher with several packs can group them with up to three parts separated by dots, such as `CNT.GWS` for `MP.CNT.GWS.1001`. No two installed packs may share a code, or have one code that starts with the other (`CNT` and `CNT.GWS`), so `Install-MtPack` refuses the second one. Any code is allowed, but pick one that the [Maester Packs site](https://packs.maester.cloud) does not list as claimed, so people can install your pack next to others. |
| `Suites` | Folders of native tests. Each needs a `suite.json` whose `Id` is the code or starts with it, such as `CNT.GoogleWorkspace`. |
| `Helpers` | Folders of `.ps1` files that hold functions the tests call. |
| `Connectors` | Services the pack connects to (below). |
| `Uses` | What the pack reaches, shown when it is installed. An unknown key under `Uses` is an error, so a pack cannot hide what it reaches behind a key an older Maester does not show. |
| `Uses.Modules` | PowerShell Gallery modules, each pinned to one exact `Version`. Maester saves them into its own store and checks their files before every load. A module the user installs themselves can only be run in a separate process: `{ "Name": "X", "MinimumVersion": "2.0.0", "Install": "User", "Process": "Child" }`. |
| `Requires.Packs` | Other packs this one needs: `{ "Id": "contoso.base", "Source": "contoso/maester-base", "Version": ">=1.0" }`. They are installed first, with their own approval. |
| `Exports` | Functions of this pack that packs requiring it may call. |
| `Pricing` | `Free` (the default) or `Paid`, for a pack that needs a paid product or subscription to be useful. The pack index shows it; Maester does not act on it. |
| `Banner` | An image in the pack that the pack index shows when it features the pack. See [Banner image](#banner-image). |
| `Guide` | A JSON file in the pack that says how to install, connect and run it, for its page on the pack index. See [The pack page](#the-pack-page). |
| `Settings` | The `GlobalSettings` keys the pack reads. See [Settings](#settings). |
| `Report.Navigation` | The pack's menu in the Maester report. See [The pack's menu in the report](#the-packs-menu-in-the-report). |
| `EnabledByDefault` | For a pack that ships with Maester: `false` to leave it off until the config turns it on. |

## Banner image

A banner is optional. When the maintainers feature a pack on [packs.maester.cloud](https://packs.maester.cloud), the
card shows the banner beside the pack's description; without one, the card shows the pack's code and name on a dark
background.

- **File:** `.png`, `.jpg` or `.webp`, at most 1 MB, inside the pack, for example `"Banner": "assets/banner.png"`.
  The index reads the type from the file's contents, not its name, and SVG is refused. A banner that fails these
  checks is left out and the card falls back to the code and name.
- **Size:** 1200 × 630 pixels (the 1.91:1 shape of a social preview). Another size is cropped to fill that shape.
- **Shown small:** about 600 pixels wide on a desktop and 340 on a phone. Use at most eight words of text, set large,
  and keep it away from the edges.
- **Your own brand.** Use another company's logo only for a pack built on that company's product, and only with its
  permission. Don't add words such as "Official" or "Verified": the index shows its own badges, and a banner that
  suggests an endorsement it doesn't have can get the pack removed.
- **No secrets or real data:** a screenshot must not show a real tenant, user or key.

### Making one with an AI image tool

Any image tool that takes a text prompt can make a banner. Image tools often misspell words, so check every letter,
or ask for an image with no text and add the title yourself in an editor. Fill in the parts in angle brackets:

```text
A wide banner image, 1200 x 630 pixels, for a software add-on called "<pack name>".
It checks the security settings of <service, for example Google Workspace> for IT administrators.
Style: clean, modern, flat illustration on a <dark navy / deep teal / near-black> gradient background,
with a simple abstract motif suggesting <what the pack checks, for example identity, email, devices>.
Text: the title "<pack name>" in large, bold, white sans-serif letters on the left,
and the line "<one short phrase, at most six words>" below it in smaller letters.
Leave a margin of at least 60 pixels on every side. No other text, no logos, no people,
no photos of screens, no badges or seals.
```

For an image without text, replace the `Text:` line with `No text at all; leave the left half plain for a title.`

## The pack page

Every pack's page on [packs.maester.cloud](https://packs.maester.cloud) has the same sections: Install, Connect,
Run, App-only, Tests, Security and Support. The index fills what it can from the manifest:

- the modules in `Uses.Modules`, with the line that installs each one;
- the Graph permissions in `Uses.GraphScopes`, which an app needs to run the pack app-only;
- the tests, with their products, from `Suites` (and `Catalog` for a family test that expands when it runs).

The rest comes from the Guide, a JSON file in the pack named by the manifest's `Guide` key, such as
`"Guide": "maester-pack.guide.json"`. Its schema is
[maester-pack-guide-1.0.json](https://maester.dev/schemas/maester-pack-guide-1.0.json); add it as `$schema` so
editors check the file as you type. Commands in it are shown as text and never run. A short example:

```json
{
  "$schema": "https://maester.dev/schemas/maester-pack-guide-1.0.json",
  "Summary": "Checks the security settings of Google Workspace.",
  "Prerequisites": [{ "Text": "A Google Workspace super admin account." }],
  "Connect": { "Steps": [{ "Text": "Connect Maester, then sign in to Google when asked", "Command": "Connect-Maester" }] },
  "Run": {
    "Commands": [{ "Text": "Only this pack", "Command": "Invoke-Maester -Pack CNT -SkipBuiltIn" }],
    "Options": [{ "Name": "GoogleWorkspace.Customer", "Default": "my_customer", "Text": "The customer ID to read." }]
  },
  "AppOnly": { "Supported": true, "Methods": { "Certificate": true, "ClientSecret": true }, "Text": "Use a service account key." },
  "Screenshots": [{ "Path": "assets/report.png", "Caption": "The pack's results in the Maester report" }],
  "Support": { "Pack": "https://github.com/contoso/maester-google-workspace/issues" }
}
```

Maester checks that the Guide is a JSON object and that its screenshots (at most six) are `.png`, `.jpg` or `.webp`
paths inside the pack. Like the banner, a screenshot that really is an image of at most 1 MB is not counted as
binary content. Any section can end with `"More": "<https link>"` to your own documentation. The
[Zero Trust Assessment pack's guide](https://github.com/maester365/maester/blob/main/packs/zero-trust-assessment/maester-pack.guide.json)
is a full example.

## The pack's menu in the report

To show a logo in the header of the pack's page instead of its `Code`, set `Report.Logo` to a mark the report draws
itself: `microsoft`, or a product key such as `entra` or `defender`. The report never loads an image from a pack, and
a name it does not know shows the `Code`. The icon on the menu's rail always shows the `Code`.

```json
"Report": { "Logo": "microsoft" }
```

In the Maester report, each pack has a row on the home menu. Selecting it opens the pack's own menu beside an icon
rail, as a resource menu does in the Azure portal. Without a `Report.Navigation` key, the menu has an overview, one
page for each value of the first field your tests set in `Fields`, the HTML reports your tests attached, the guide's
pages and an About page.

To choose the menu yourself, list its entries. An entry is a row (`Title`) or a heading (`Section`) that opens one of
the report's own views. Any entry can hold `Items`, to any depth:

```json
"Report": {
  "Navigation": [
    { "Title": "Overview", "View": "pack" },
    { "Section": "Zero Trust plan", "Id": "plan", "View": "plan", "Attachment": "zero-trust-workshop", "Expand": ["pillars", "areas", "lanes"] },
    { "Section": "Assessment results", "Items": [
      { "Title": "All results", "Id": "results", "View": "tests" },
      { "Title": "Not met", "Id": "not-met", "View": "tests", "Result": "Failed" },
      { "Title": "By pillar", "Id": "by-pillar", "View": "tests", "GroupBy": ["Pillar", "Category"] },
      { "Title": "Assessment report", "View": "attachment", "Attachment": "Zero Trust Assessment" }
    ] },
    { "Title": "About this pack", "Id": "about", "View": "about" }
  ]
}
```

| View | What the page shows | Keys |
| --- | --- | --- |
| `pack` | The pack's overview: who publishes it, how its tests did, and its results. | |
| `tests` | The pack's results. `GroupBy` adds a menu level for each field it names: `["Pillar", "Category"]` lists each pillar, and each pillar's categories under it. | `Result`, `Filter` (field names to a value or a list of values), `GroupBy` |
| `plan` | A JSON attachment drawn by a view the report ships. Today that is the Zero Trust Workshop plan (`maester.zero-trust-workshop/1`). `Expand` lists its levels in the menu. | `Attachment`, `Expand` |
| `attachment` | An HTML report a test attached with `Add-MtReportAttachment`. | `Attachment` (the `-Name` you gave it) |
| `guide` | A page of the pack's [Guide](#the-pack-page): `start` (summary, prerequisites and links), `connect` or `run`. | `Page` |
| `about` | The pack's ID, version, publisher, status in the run and home page. | |

`Id` is the entry's part of the page's link (`#/pack/<code>/<id>`); without it, the report uses the title in lower
case with dashes. Keep it stable, so that links people share keep working.

Nothing in the menu runs: the report draws only its own views, so a pack never adds code to the report people share.
Maester refuses a manifest whose menu is malformed, such as an entry with neither a `Title` nor a `Section`, or a
`plan` entry without its `Attachment`. A view or key it does not know is only a warning, and the report leaves that
entry out, so a pack written for a newer Maester still loads. Maester also warns about a menu more than five levels
deep: each level indents the menu and shortens its labels.

## Tests and helpers

A pack's tests are [native tests](../writing-tests/index.mdx) with IDs `MP.<Code>.<n>`, or `MP.<Code>` for a family whose instances add their own part. (Packs that ship with
Maester are part of it and drop the `MP.`: the Zero Trust Assessment pack's tests are `ZTA.<n>`.) Every `.ps1` in a suite
or helper folder holds only functions: code outside a function is refused, because it would run while the pack
loads. A pack loads into a private module of its own, so its tests can call its helpers and Maester's exported
commands. One pack does not see another pack's functions unless it requires that pack and the pack lists them in
`Exports`. This keeps cooperating packs from colliding; it is not a security boundary, because code in the same
PowerShell process can reach anything in it (see [Pack security](./security.md)).

The scan flags, and raises the risk of, what a reviewer should look at: binary files, dynamic code
(`Invoke-Expression`, `[scriptblock]::Create`), starting processes, requests to hosts that `Uses.Network` does not
list, and module imports that `Uses.Modules` does not list.

### Mapping a pack's results to a framework

A pack test maps itself to a requirement with `Control`, like any test (see
[Benchmarks and framework files](../writing-tests/index.mdx#benchmarks-and-framework-files)). It can name a
framework that ships with Maester, such as `cisa-scuba`, or one that the pack's own `suite.json` declares. A family
whose instances each report on a different requirement sets `Control` on each instance instead, with the other
attributes an instance may set (see [Families](../writing-tests/index.mdx#families-one-test-several-results)):

```powershell
foreach ($policy in $scubaResults) {
    @{ Id = $policy.PolicyId; Title = $policy.Requirement; Control = "cisa-scuba:$($policy.PolicyId)"; Data = $policy }
}
```

Each row then has its own `Benchmark`, and the report's framework view shows the pack's result under the
requirement next to any Maester test that names the same one.

## Showing a long-running test's output and progress

A test that runs a tool for a long time, such as an assessment in another PowerShell process, can show the person
running Maester what the tool is doing while it runs. In the full-screen dashboard both go into a box in the main
column; in the compact layout, CI and redirected output they are written to the console, indented.

- `Write-MtTestOutput` shows the tool's log. `-Title` opens the box, each line
  scrolls into it, `-Progress` pins a changing line at the bottom, and `-Complete` closes it (Maester closes it when
  the test ends).
- `Write-MtTestProgress` shows where the tool is, as a header at the top of
  the box: the stage (`-Stage`, `-StageNumber`, `-StageCount`), a bar of the items done (`-Completed`, `-Total`,
  `-Failed`, `-Running`), what it is working on (`-Current`, `-Status`), and the errors it reported
  (`-ErrorMessage`), which the box also counts. With `-Stages` (the names of all the stages) the stages are drawn
  as a track, like the phases of a Maester run. With `-Items` (each with `Id`, and optionally `Title`, `Status`,
  `Detail` and `Seconds`) the box lists the items running and those that finished, with their result and time,
  the way the dashboard lists Maester's own tests; outside the dashboard each finished item is written once. Each
  call replaces the progress before it, so pass everything you know each time; a call with only `-ErrorMessage`
  keeps the progress.
- When the tool runs tests of its own, `-Results` (`Passed`, `Failed`, `Error`, `Investigate`, `Skipped`,
  `Running`, `Total` and optionally `Name`) and `-Groups` (the same counts with a `Name` each, such as the pillars
  of an assessment, with `-GroupLabel 'Zero Trust Pillars'`) show them in the dashboard in place of Maester's own while the
  box is open: in the Results panel and the result blocks, and where the products are listed. The times of the
  items that finished go into the Pace graph next to the logo and the Slowest panel.

```powershell
Write-MtTestOutput -Title 'Collecting the tenant data'
Write-MtTestProgress -Stage 'Exporting' -StageNumber 1 -StageCount 3 -Completed 12 -Total 40 -Running 2 -Current 'Users', 'Groups'
Write-MtTestProgress -ErrorMessage 'The export of Devices failed: Forbidden'
```

A tool in another process does not need to call Maester to report its progress. Pipe its output to
`Write-MtTestOutput`, and have it write lines of this form to standard output, from any language:

```text
##maester[progress] {"stages":["Export","Tests","Report"],"stageNumber":1,"stage":"Exporting","completed":12,"total":40,"failed":1,"items":[{"id":"Users","detail":"page 3","seconds":12},{"id":"Groups","status":"Passed","seconds":4.2}]}
##maester[progress] {"stage":"Running tests","results":{"passed":80,"failed":12,"running":4,"total":300},"groupLabel":"Zero Trust Pillars","groups":[{"name":"Identity","passed":50,"failed":8,"total":120}]}
##maester[error] The export of Devices failed: Forbidden
```

These lines set the header instead of showing in the log. The JSON object takes the names of the parameters of
`Write-MtTestProgress` in any case (`error` for `-ErrorMessage`), and leaves out what it does not know. Write a
`progress` line when something changes (at most a few times a second): each one is drawn. A line that cannot be read is dropped.

The [Zero Trust Assessment pack](./zero-trust-assessment.md) is a worked example: its child process reads the
progress the assessment keeps and the progress logs it writes, once a second, and writes these lines (see
`Invoke-MtZtaCollection` in `packs/zero-trust-assessment/tests/Test.ZTA.ps1`).

## Settings

A pack that reads values from `GlobalSettings` of the user's config declares them in its manifest, each named
`Namespace.Key` so that it cannot collide with a setting of Maester or of another pack:

```json
"Settings": {
  "Contoso.Regions": { "Type": "string[]", "Default": ["eu"], "AllowedValues": ["eu", "us", "apac"], "Description": "Regions to check." },
  "Contoso.ApiKey": { "Type": "string", "Sensitive": true, "Description": "Key for the Contoso API." }
}
```

| Key | Content |
| --- | --- |
| `Type` | Required: `int`, `bool`, `string`, `string[]`, `object` or `object[]`. |
| `Default` | What `Get-MtSetting` returns when the config does not set the setting. |
| `Description` | One sentence for the user. |
| `AllowedValues` | The values a `string` or `string[]` setting accepts. |
| `Kind` | What a string holds, such as `Entra.Group`, so a user interface can offer a picker. |
| `Sensitive` | `true` to record the value as `[redacted]` in the results. |

While the pack is in a run, Maester checks the config against these declarations before any test starts, so a
wrong type or a value that is not allowed stops the run with one message. A test receives a setting as a
parameter bound to it, which also lets a `TestSettings` row override it for that test:

```powershell
param(
    # Regions to check.
    [MaesterParameter(Setting = 'Contoso.Regions')]
    [ValidateSet('eu', 'us', 'apac')]
    [string[]] $Regions = @('eu')
)
```

A connector or a helper that needs a setting before any test runs reads it with `Get-MtSetting -Name
'Contoso.ApiKey'`. Declare every setting the pack reads: a setting that is not declared has no default, is not
checked, and is never redacted.

## Connectors

A connector adds a service to Maester. Its file holds a `Connect` and a `Test` function, and optionally
`Disconnect`, `Renew` and `Describe`:

```powershell
function Connect-CntGoogleWorkspace {
    [CmdletBinding()]
    param([string] $Domain, [string] $CredentialPath)

    $token = Get-CntAccessToken -Domain $Domain -CredentialPath $CredentialPath
    Set-MtConnectionState -Value @{ Token = $token.AccessToken; ExpiresOn = $token.ExpiresOn } -Tenant $Domain
}

function Test-CntGoogleWorkspace {
    [CmdletBinding()]
    [OutputType([bool])]
    param()
    [bool](Get-MtConnectionState)
}
```

- `Connect-Maester -Service CNT.GoogleWorkspace` calls `Connect` with the parameters it has from `-ServiceSetting`,
  `MAESTER_CNT_GOOGLEWORKSPACE_<PARAMETER>` and `Connections.CNT.GoogleWorkspace` in the config.
- `Set-MtConnectionState` keeps what the tests need for the PowerShell session only; `Get-MtConnectionState`
  reads it back in a test or helper. A pack can read its own connection and those of packs it requires, never
  another pack's.
- `Test` decides whether the tests that need the service run. With `ExpiresOn` in the state, Maester calls `Renew`
  shortly before it expires.
- The service name is `<Code>.<Name>`. Tests declare it like any service: `Service = 'CNT.GoogleWorkspace'`.
  `"OptIn": true` makes its tests not run, rather than skipped, while it is not connected.

## Publishing your pack

Once the pack has a release, anyone can install it from its repository with `Install-MtPack <owner>/<repo>`. To
help people find it, list it on a pack index: a site that collects packs, checks them and shows each one's page.
An index only lists a pack. People still install it straight from your repository, and Maester runs the same checks
at install whichever site they found it on.

### Maester Packs

[packs.maester.cloud](https://packs.maester.cloud/) is the index the Maester team runs. To list a pack there:

1. Go to [packs.maester.cloud/publish](https://packs.maester.cloud/publish) and choose
   **Yes, I have a pack repository**.
2. Sign in with GitHub as an admin of the repository. The repository must be public; private and internal
   repositories are never listed.
3. Pick the repository, and the folder if the pack is not at its root, then press **Check**. It reads the latest
   release and its `maester-pack.json`, and says what to fix.
4. Press **Publish**. The site registers the pack's `Guid` and `Code` to the repository's owner, so nobody else can
   publish a pack with them, and queues the pack.

Within about ten minutes the index runs `Test-MtPack`'s checks and its security scan on the release and lists the
pack, with its page at `/p/<code>`. If the pack is not listed, its status page at `/status/<owner>/<repo>` says why
and what to fix. Every later release is picked up on its own, so you publish only once. **My Packs** on the site
shows your packs and how many tenants run them, and lets you hide one from the site for a while.
