Security overview
This document is for security reviewers. It covers what Cloud Certainty Watchtower protects against, how users are identified, how access is limited, where data goes and how it is encrypted. Everything described here runs in your own AWS account.
Watchtower reads AWS Security Hub findings across your organisation, writes workflow changes back when you allow it, and, when you configure it, reads your GitHub repositories and opens pull requests in them. Its controls make sure that each person sees and changes exactly the findings an administrator granted them, that AWS itself bounds what each session can do, that tokens for code hosts never leak, that changes to code always go through a pull request a person reviews, and that every action can be traced to a person.
Threat model
| Threat | Mitigation |
|---|---|
| A user sees findings of accounts nobody granted them | Enforced by Watchtower on every request: every search, count, facet, issue, Overview, export and detail is computed from the findings whose connection, account and Region match the caller's grants, and a finding outside them answers "not found" (see Account grants and Security Hub) |
| A user changes findings they may only read | triage is a separate permission, checked for every finding in a request. The AWS session of a user without triage carries a session policy without securityhub:BatchUpdateFindings, and a connection whose owner chose Read has a role without it, so AWS refuses the change even if Watchtower's own check had a bug |
| Watchtower is used to change AWS resources | Connection roles have no write permission except securityhub:BatchUpdateFindings (workflow status, note, severity), and only with Triage. No IAM, CloudFormation, DynamoDB or S3 permissions in your accounts. Fixes change code, never infrastructure |
| A fix pull request does something unexpected | Fixes are a fixed list of small text edits per control, tested against known files. The patch is recomputed against the branch head, committed only to a new branch named watchtower/fix-..., and proposed as a pull request. Nothing is merged or deployed by Watchtower; your review and pipeline decide. Needs the fix permission and the Business plan; every pull request is audited |
| A GitHub or SonarCloud token leaks | Tokens and GitHub App private keys are write-only in the portal, stored in AWS Secrets Manager encrypted with the stack's key, read only by the API, the sync worker and the scanner task, and never returned, logged or written to the audit log. Error messages from code hosts are redacted. GitHub App installation tokens last at most an hour |
| A forged SARIF upload | The upload route is the only one without a user sign-in. It needs an ingest token scoped to the repository, unexpired and unrevoked; Watchtower stores only its SHA-256 hash. Uploads are throttled, size-limited (4 MB as sent, 50 MB decompressed, 50,000 results) and audited. Uploaded findings only inform; they change nothing in AWS or GitHub |
| A malicious repository attacks the scanner | Archives are extracted with path checks (no absolute paths, .., links or devices) and size caps into the task's ephemeral storage, with a read-only root filesystem and all Linux capabilities dropped. The task role can read only the integration secrets and write only scan results. Its security group allows no inbound traffic and only outbound HTTPS. The task is discarded after each scan |
| An exception hides a risk forever | Exceptions need a reason and an expiry (at most a year by default), need triage over their whole scope, are listed for everyone, are audited, and end on their own: the findings come back as NEW with a note |
| A third party assumes a spoke role ("confused deputy") | Spoke roles trust only the hub's API and sync role ARNs, and require your external ID and a source identity |
| Someone impersonates another user | Federated sign-in; the API trusts only emails the user can't choose, and binds the bootstrap admin to an immutable ID (see Identity) |
| A stolen token is used from elsewhere | One-hour ID tokens, revocable 8-hour refresh tokens, optional IP allowlist checked on every call |
| Watchtower is used to change its own stack | Connection roles have no IAM, CloudFormation or DynamoDB permissions, and the local role is explicitly denied every S3 action on Watchtower's own buckets |
| Actions can't be traced | Application audit log, plus SourceIdentity = <user email> on every assumed-role session, so CloudTrail in the target account names the person behind each Security Hub call. Notes written to Security Hub carry the user's email as their author |
| Data leaves your organisation | No vendor service in the data path and no telemetry. The stack contacts only AWS, the code hosts you configure, and optionally the vendor's public release manifest |
Out of scope: a malicious administrator of the AWS account (they can change any IAM policy), the security of your GitHub organisation and its branch protection, and the security of the identity provider and of users' devices.
Zero-trust controls
| Principle | Control |
|---|---|
| Strong identity on every request | Every API route requires a Cognito ID token, validated by the API Gateway JWT authorizer (issuer and audience), except the SARIF upload route, which requires an ingest token. Admin routes check the role on each call. |
| Least privilege | Each component has its own role with resource-scoped permissions (IAM summary). Users act only through roles under the IAM path /watchtower/, narrowed per request by a session policy. |
| Never trust the network | No inbound connections to anything in your VPC. Optional IP allowlist (Business and above) checked on every call; outside it, every route except the sign-in check returns 403 ip_not_allowed. |
| Short-lived credentials | STS sessions of 15 minutes per user and connection. GitHub App installation tokens of at most an hour. |
| Strong authentication | Your identity provider's MFA applies. Cognito-only users must use TOTP MFA and passwords of at least 14 characters. User-existence errors are suppressed. |
| Changes need a person | Findings change only through a person with triage; code changes only through a pull request a person merges; exceptions expire. |
| Hardened portal | Strict CSP, HSTS, X-Frame-Options: DENY, Referrer-Policy: no-referrer, restrictive Permissions-Policy, no source maps (Portal). |
| Auditability | Application audit log, API access logs, and CloudTrail in every connected account with the user's SourceIdentity. |
Identity and authorisation
The API trusts the Cognito ID token's signature, issuer and audience (checked by API Gateway), and
then decides who the caller is from its claims. Roles, grants, tasks and the bootstrap admin are keyed
on the email claim, so a user who could pick their own email could take over someone else's
access. The API therefore accepts the email only when it is one the user can't choose; otherwise it
refuses the caller with 403 forbidden on every route.
| How the user signs in | Email trusted when | Why |
|---|---|---|
| SAML: IAM Identity Center, Entra ID, Okta | always | The email is asserted by your directory and signed by the IdP. Map it from an attribute users can't edit (Identity providers). |
| OIDC | email_verified is true | Many OIDC IdPs let users or guests set their email. The stack maps the IdP's verification claim to Cognito's email_verified. |
| Cognito user pool user | email_verified is true | Users are created by administrators and can't change their email. |
The provider is read from the signed token, never from anything the client sends.
Bootstrap admin. BootstrapAdminEmail is always an admin, so it is bound to an immutable
identity: the first trusted sign-in with that email records the caller's Cognito user ID, and from
then on only that user is the bootstrap admin. Sign in as the bootstrap admin straight after
deployment. Resetting the binding is described in the admin guide.
Permissions, grants and session policies
What a user can do is the intersection of:
- The connection's IAM role, set by the account owner:
LocalAccessLevelon the hub stack,AccessLevelon each spoke stack.Readallows Security Hub reads;Triageaddssecurityhub:BatchUpdateFindings. Watchtower can never do more in an account than its role allows. - Watchtower grants: a subject (user or group), a connection (or all), AWS account patterns
(
*,?), Regions (or all), and the permissionsview,triage(impliesview) andfix(impliesview). Administrators hold every permission, still bounded by the role. See the admin guide.
For every request that reaches Security Hub, the API calls sts:AssumeRole on the connection's role
with:
SourceIdentity= the user's email. It can't be changed for the rest of the session, also in role chains, and it appears in CloudTrail for every Security Hub call in the target account (the sync worker useswatchtower-sync);- a role session name
wt-<email>, also visible in CloudTrail; - the external ID for spoke roles;
- a session policy (for non-admins) for the scope of the call:
| Scope | Allows |
|---|---|
Read | Finding detail and history: securityhub:GetFindings, GetFindingHistory, DescribeHub, standards and controls, finding aggregators |
Triage | Read plus securityhub:BatchUpdateFindings, only for users with triage |
Sync | Read plus organizations:ListAccounts and DescribeOrganization (account names), for the sync worker |
Account grants and Security Hub
AWS Security Hub has no IAM permissions per member account. GetFindings and
BatchUpdateFindings act on the hub (arn:aws:securityhub:<region>:<account>:hub/default) of the
account they are called in: in the delegated administrator, they read or change the findings of
every member account. No IAM policy or session policy can say "only the findings of account
111122223333".
So the two layers do different jobs:
- Watchtower enforces which findings each person may see and change. The index holds every finding
of a source and is filtered to the caller's grants on every read; every finding in a change request is
checked against the caller's
triagegrants before anything is sent; findings outside the grants answer "not found". This is covered by tests on every route. - AWS enforces which actions each session may take. A user without
triagegets a session that AWS refuses to let change any finding; aReadconnection's role can't change findings at all; and no role can do anything outside Security Hub reads,BatchUpdateFindingsand the Organizations account list.
If you need AWS itself to separate accounts, connect separate Security Hub administrators (for example per business unit) as separate connections with their own roles.
IAM summary
| Role | Assumed by | Permissions |
|---|---|---|
API role (<prefix>-api-<region>) | API Lambda | Its two DynamoDB tables; the data bucket; sts:AssumeRole, SetSourceIdentity and TagSession on roles under /watchtower/ only; invoking the sync worker; Secrets Manager on <prefix>/integrations/* only; ecs:RunTask on the scanner task definition, with iam:PassRole on the scanner's two roles only; reading the account's licences in AWS License Manager; its log group; KMS only through DynamoDB, S3 and Secrets Manager. No Security Hub access of its own |
Sync role (<prefix>-sync-<region>) | Sync worker Lambda | As the API role, plus invoking itself. No Security Hub access of its own |
Local role (/watchtower/<prefix>-local-<region>) | API and sync roles, with a source identity | Security Hub reads; organizations:ListAccounts and DescribeOrganization; with Triage, securityhub:BatchUpdateFindings on this account's hub. Explicit deny of all S3 actions on Watchtower's artifacts and data buckets. No IAM, CloudFormation or DynamoDB |
Spoke role (/watchtower/<RoleName>, other accounts) | API and sync roles, with the external ID and a source identity | As the local role, by the spoke stack's AccessLevel |
| Scanner task role | Scanner task (Fargate) | Read the integration secrets; read and write scans/ and tmp/ in the data bucket; write scan run items in the main table only; its log group |
| Update notifier role | Lambda | Its settings and audit items; publish to the update topic |
| Portal deployer role | Lambda | Deploy the portal's Amplify app from one file in the artifacts bucket |
| Installer roles | ECS (one-off task) and Lambda | Run the installer task; read the account's licences; pull the image from the AWS Marketplace registry; write the code to the artifacts bucket |
* resources appear only where AWS has no resource-level permissions (Security Hub reads, AWS
Organizations, License Manager).
Data flows
| Flow | Path | Protection |
|---|---|---|
| Sign-in | Browser ⇄ Cognito managed login ⇄ your IdP | TLS; SAML signatures or OIDC; authorization code flow with PKCE |
| API | Browser → API Gateway → Lambda | TLS 1.2+; JWT on every call; CORS limited to the portal origin; throttled |
| Findings sync | Sync worker → STS → Security Hub in the hub or delegated administrator account → data bucket | TLS; SourceIdentity = watchtower-sync; Sync session policy; private, TLS-only bucket |
| Finding detail and triage | Lambda → STS → Security Hub | TLS; per-request, per-user scoped STS credentials with SourceIdentity |
| Code | Lambda, sync worker and scanner task → GitHub or GitHub Enterprise Server | TLS; token from Secrets Manager; responses size-capped |
| SonarCloud, SonarQube | Sync worker → SonarCloud or your SonarQube | TLS; token from Secrets Manager |
| SARIF upload | CI → API Gateway → Lambda | TLS; ingest token; size limits; throttled |
| Licence | Installer and API → AWS License Manager in your account | TLS. A read of the account's licences for the product |
| Code install | AWS Marketplace container registry → installer task → artifacts bucket | Image pulled by digest; every file checked against a SHA-256 manifest; written to a private, TLS-only bucket |
What stays in your account
Everything: identities, connections and external IDs, grants, the findings index, posture and trends, tasks and comments, exceptions, saved views, code indexes (resource names, types, tags and line numbers, not whole files), scan results, integration secrets, usage figures and the audit log. File contents are read from GitHub when someone opens a file or previews a fix, and are not stored.
Cloud Certainty operates no service in the data path, and Watchtower sends no telemetry. The AWS
Marketplace container registry and the vendor's public bucket in us-east-1 (templates and the release
manifest) are read-only distribution points used at install and upgrade, and, with
UpdateChecks = Enabled, for a daily read of the public release manifest that sends nothing about
you. Links to cloudcertainty.com in the portal are plain links, followed only when someone clicks them.
Code hosts
The only places Watchtower sends data outside AWS are the code hosts you configure:
- GitHub or GitHub Enterprise Server. Watchtower reads repository trees, files and archives. When a person opens a fix pull request or creates a GitHub issue, Watchtower writes a branch, commits and a pull request, or an issue, whose text includes the finding titles, AWS account ids and resource ids concerned (never notes). Choose repositories whose visibility suits that information.
- SonarCloud or SonarQube. Watchtower only reads issues and hotspots of the projects you map.
Use a GitHub App or fine-grained tokens limited to the repositories Watchtower indexes, with write permissions only for the features you use (Code integrations).
Encryption
- In transit. TLS for every AWS API, every code host and the portal (HTTPS only, with HSTS). The artifacts and data buckets deny non-TLS requests.
- At rest. With
KmsKeyArn, your customer managed key encrypts the DynamoDB tables, CloudWatch Logs, the update topic, the artifacts and data buckets and the integration secrets. Without it, AWS owned or AWS managed keys and SSE-S3 are used. Security Hub keeps its own encryption.
Portal hardening
Amplify Hosting serves the portal with these headers:
- A strict
Content-Security-Policy: no inline or third-party script,frame-ancestors 'none', and AWS endpoints allowed only for sign-in and the API. Strict-Transport-Security,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,X-Frame-Options: DENY, and a Permissions-Policy that turns off camera, microphone, geolocation, payment and USB.
Charts are drawn in the browser; nothing is loaded from third parties. Tokens are kept in session storage, never in cookies; refresh tokens last 8 hours and can be revoked.
Audit
Every administrative change (roles, connections, grants, settings, integrations, repositories, ingest
tokens), every change to findings in Security Hub, every task created or deleted, every exception
created, changed, revoked or applied, saved views, fix pull requests, GitHub issues, scans, SARIF
uploads and manual syncs are written to the audit log with the actor, source IP and target. Admins
read it in Administration › Audit log. Events are kept for the plan's audit days: 30 on Starter, 90
on Team, 365 on Business and 3,650 on Enterprise. For each AWS call, CloudTrail in the connected
account shows the assumed-role session with sourceIdentity set to the user's email. Forward
CloudTrail and Watchtower's log groups to your SIEM.
Licensing
Watchtower reads its AWS Marketplace licence from AWS License Manager in your account: at install and
in the API. Nothing about your usage or data is sent anywhere. Without a valid licence the installer
stops and the API answers 402 licence_required. If License Manager is unreachable, or a licence that
was active disappears (which bridges a move between plans), Watchtower keeps working for up to 72
hours from the last successful check. Your data stays in place either way.
Shared responsibility
- Keep
BootstrapAdminEmailon a protected identity and enforce MFA at your IdP. Grant Admin to a second person or group. - Keep connection roles at
Readunless your team triages in Watchtower, and granttriageto the people who own each account. - Grant groups rather than single users, with account patterns per team.
- Prefer a GitHub App installed on the infrastructure repositories only, or fine-grained tokens limited to them. Protect default branches so that Watchtower's pull requests need review.
- Review exceptions when they expire, and keep their maximum length short.
- Scope each ingest token to one repository, give it an expiry, and rotate it.
- On Business and above, set the IP allowlist to your corporate egress ranges if users should only connect from managed networks.
- Forward CloudTrail and Watchtower's log groups to your SIEM.
Something unclear or missing? Email [email protected].