Skip to content

Commit 3e8b286

Browse files
feat: confluence revamp (#330)
- Revamp confluence to use Confluence REST API v2 - Add flag to scan for a specific page ID - Split the old --spaces flag into --space-keys and --space-ids. - Add rate-limit handling and minimize the number of requests (https://developer.atlassian.com/cloud/confluence/rate-limiting/) --------- Co-authored-by: Rui Oliveira <[email protected]>
1 parent 157c057 commit 3e8b286

File tree

15 files changed

+3937
-925
lines changed

15 files changed

+3937
-925
lines changed

.github/workflows/codecov.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ on:
1111
jobs:
1212
run:
1313
runs-on: ubuntu-latest
14-
env:
15-
go-version: 'stable'
1614

1715
steps:
1816
- name: Checkout code
@@ -21,11 +19,11 @@ jobs:
2119
- name: Set up Go
2220
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
2321
with:
24-
go-version: ${{ env.go-version }}
22+
go-version-file: go.mod
2523
env:
2624
GOPROXY: direct
2725
GONOSUMDB: "*"
28-
GOPRIVATE: https://github.com/CheckmarxDev/ # Add your private organization url here
26+
GOPRIVATE: https://github.com/CheckmarxDev/
2927

3028
- name: Install dependencies
3129
run: go install golang.org/x/tools/cmd/cover@latest

.github/workflows/new-rules.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ jobs:
1212
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
1313
- uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
1414
with:
15-
go-version: "^1.22"
15+
go-version-file: go.mod
1616
- name: Check Gitleaks new rules
1717
run: go run .ci/check_new_rules.go

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959

6060
- uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
6161
with:
62-
go-version: "^1.22"
62+
go-version-file: go.mod
6363

6464
- name: Go Mod Tidy
6565
run: go mod tidy

.github/workflows/validate-readme.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
1515
- uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
1616
with:
17-
go-version: "^1.22"
17+
go-version-file: go.mod
1818

1919
- name: update README
2020
run: ./.ci/update-readme.sh

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# and "Missing User Instruction" since 2ms container is stopped after scan
44

55
# Builder image
6-
FROM checkmarx/go:1.24.4-r0-ae7309142bb6bd@sha256:ae7309142bb6bd82e0272c3624ec53c0c68d855f6b63e985c5caaff5c1705644 AS builder
6+
FROM checkmarx/go:1.25.3-r0-b47cbbc1194cd0@sha256:b47cbbc1194cd0d801fe7739fca12091d610117b0d30c32b52fc900217a0821a AS builder
77

88
WORKDIR /app
99

README.md

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ Usage:
176176
2ms [command]
177177
178178
Scan Commands
179-
confluence Scan Confluence server
179+
confluence Scan Confluence Cloud
180180
discord Scan Discord server
181181
filesystem Scan local folder
182182
git Scan local Git repository
@@ -274,30 +274,56 @@ This command is used to scan a [Confluence](https://www.atlassian.com/software/c
274274
2ms confluence <URL> [flags]
275275
```
276276

277-
| Flag | Type | Default | Description |
278-
| ------------ | ----- | ------------------------------ | -------------------------------------------------------------------------------- |
279-
| `<url>` | string | - | Confluence instance URL in the following format: `https://<company id>.atlassian.net/wiki` |
280-
| `--history` | - | Doesn't scan history revisions | Scans pages history revisions |
281-
| `--spaces` | string | all spaces | The names or IDs of the Confluence spaces to scan |
282-
| `--token` | string | - | The Confluence API token for authentication |
283-
| `--username` | string | - | Confluence user name or email for authentication |
277+
| Flag | Type | Default | Description |
278+
| --------------- | ----------- | ------- |----------------------------------------------------------------------------------|
279+
| `--space-keys` | string list | (all) | Comma-separated list of space **keys** to scan. |
280+
| `--space-ids` | string list | (all) | Comma-separated list of space **IDs** to scan. |
281+
| `--page-ids` | string list | (all) | Comma-separated list of **page IDs** to scan. |
282+
| `--history` | bool | `false` | Also scan **all versions** of each page (page history). |
283+
| `--username` | string | | Confluence username/email (used for HTTP Basic Auth). |
284+
| `--token-type` | string | | Token type for Confluence API. Accepted values: `api-token`, `scoped-api-token`. |
285+
| `--token-value` | string | | The API token value. **Required** when `--token-type` is set. |
284286

285-
For example:
287+
#### Authentication
288+
- To scan **private spaces**, provide `--username`, `--token-type` and `--token-value` (API token).
289+
- How to create a Confluence API token: https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/
290+
291+
#### Examples
292+
293+
- Scan **all public pages** (no auth):
294+
```bash
295+
2ms confluence https://<company id>.atlassian.net/wiki
296+
```
286297

287-
- To scan public spaces:
298+
- Scan **private pages with an api token** (requires auth):
299+
```bash
300+
2ms confluence https://<company id>.atlassian.net/wiki --username <USERNAME> --token-type api-token --token-value <API_TOKEN>
301+
```
288302

303+
- Scan **private pages with a scoped api token** (requires auth):
289304
```bash
290-
2ms confluence https://checkmarx.atlassian.net/wiki --spaces secrets
305+
2ms confluence https://<company id>.atlassian.net/wiki --username <USERNAME> --token-type scoped-api-token --token-value <API_TOKEN>
291306
```
292-
💡 [The `secrets` Confluence site](https://checkmarx.atlassian.net/wiki/spaces/secrets) purposely created with plain example secrets as a test subject for this demo
293307

294-
- To scan private spaces, authentication is required
308+
- Scan specific **spaces by key**:
295309
```bash
296-
2ms confluence <URL> --username <USERNAME> --token <API_TOKEN> --spaces <SPACES>
310+
2ms confluence https://<company id>.atlassian.net/wiki --space-keys Key1,Key2
297311
```
298-
[How to get a Confluence API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/).
299312

300-
[![asciicast](https://asciinema.org/a/607179.svg)](https://asciinema.org/a/607179)
313+
- Scan specific **spaces by ID**:
314+
```bash
315+
2ms confluence https://<company id>.atlassian.net/wiki --space-ids 1234567890,9876543210
316+
```
317+
318+
- Scan specific **pages by ID**:
319+
```bash
320+
2ms confluence https://<company id>.atlassian.net/wiki --page-ids 11223344556,99887766554
321+
```
322+
323+
- Include **page history** (all revisions):
324+
```bash
325+
2ms confluence https://<company id>.atlassian.net/wiki --history
326+
```
301327

302328
### Paligo
303329

cmd/main.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ var configFilePath string
4949
var vConfig = viper.New()
5050

5151
var allPlugins = []plugins.IPlugin{
52-
&plugins.ConfluencePlugin{},
52+
plugins.NewConfluencePlugin(),
5353
&plugins.DiscordPlugin{},
5454
&plugins.FileSystemPlugin{},
5555
&plugins.SlackPlugin{},
@@ -112,9 +112,17 @@ func Execute() (int, error) {
112112
}
113113
subCommand.GroupID = group
114114

115+
pluginPreRun := subCommand.PreRunE
115116
// Capture plugin name for closure
116117
pluginName := plugin.GetName()
117118
subCommand.PreRunE = func(cmd *cobra.Command, args []string) error {
119+
// run plugin's own PreRunE (if any)
120+
if pluginPreRun != nil {
121+
if err := pluginPreRun(cmd, args); err != nil {
122+
return err
123+
}
124+
}
125+
// run engine-level PreRunE
118126
return preRun(pluginName, engineInstance, cmd, args)
119127
}
120128
subCommand.PostRunE = func(cmd *cobra.Command, args []string) error {

engine/engine.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ func (e *Engine) detectSecrets(
342342
if buildErr != nil {
343343
return fmt.Errorf("failed to build secret: %w", buildErr)
344344
}
345-
if !isSecretIgnored(secret, e.ignoredIds, e.allowedValues) {
345+
if !isSecretIgnored(secret, e.ignoredIds, e.allowedValues, value.Line, value.Match, pluginName) {
346346
secrets <- secret
347347
} else {
348348
log.Debug().Msgf("Secret %s was ignored", secret.ID)
@@ -575,13 +575,15 @@ func getStartAndEndLines(
575575
return startLine, endLine, nil
576576
}
577577

578-
func isSecretIgnored(secret *secrets.Secret, ignoredIds, allowedValues *[]string) bool {
578+
func isSecretIgnored(secret *secrets.Secret, ignoredIds, allowedValues *[]string, secretLine, secretMatch, pluginName string) bool {
579579
for _, allowedValue := range *allowedValues {
580580
if secret.Value == allowedValue {
581581
return true
582582
}
583583
}
584-
584+
if pluginName == "confluence" && isSecretFromConfluenceResourceIdentifier(secret.RuleID, secretLine, secretMatch) {
585+
return true
586+
}
585587
return slices.Contains(*ignoredIds, secret.ID)
586588
}
587589

@@ -740,3 +742,19 @@ func (e *Engine) Scan(pluginName string) {
740742
func (e *Engine) Wait() {
741743
e.wg.Wait()
742744
}
745+
746+
// isSecretFromConfluenceResourceIdentifier reports whether a regex match found in a line
747+
// actually belongs to Confluence Storage Format metadata (the `ri:` namespace) rather than
748+
// real user content. This lets us ignore false-positives that cannot be suppressed via the
749+
// generic-api-key rule allow-list.
750+
func isSecretFromConfluenceResourceIdentifier(secretRuleID, secretLine, secretMatch string) bool {
751+
if secretRuleID != rules.GenericApiKeyID || secretLine == "" || secretMatch == "" {
752+
return false
753+
}
754+
755+
q := regexp.QuoteMeta(secretMatch)
756+
757+
pat := `<[^>]*\sri:` + q + `[^>]*>`
758+
re := regexp.MustCompile(pat)
759+
return re.MatchString(secretLine)
760+
}

engine/engine_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,120 @@ func TestGetFindingId(t *testing.T) {
727727
})
728728
}
729729

730+
func TestIsSecretFromConfluenceResourceIdentifier(t *testing.T) {
731+
tests := []struct {
732+
name string
733+
ruleID string
734+
line string
735+
match string
736+
want bool
737+
}{
738+
{
739+
name: "matches ri:secret attribute with quoted value",
740+
ruleID: rules.GenericApiKeyID,
741+
line: `<ri:attachment ri:secret="12345" />`,
742+
match: `secret="12345"`,
743+
want: true,
744+
},
745+
{
746+
name: "matches with extra whitespace and self-closing tag",
747+
ruleID: rules.GenericApiKeyID,
748+
line: `<ri:attachment ri:secret="12345"/>`,
749+
match: `secret="12345"`,
750+
want: true,
751+
},
752+
{
753+
name: "no match when value format differs (expects exact literal)",
754+
ruleID: rules.GenericApiKeyID,
755+
line: `<ri:attachment ri:secret="12345" />`,
756+
match: `secret=12345`,
757+
want: false,
758+
},
759+
{
760+
name: "no match when value appears in a different attribute",
761+
ruleID: rules.GenericApiKeyID,
762+
line: `<ri:attachment ri:filename="secret=12345" />`,
763+
match: `secret=12345`,
764+
want: false,
765+
},
766+
{
767+
name: "no match when ri: prefixes the element name (not an attribute)",
768+
ruleID: rules.GenericApiKeyID,
769+
line: `<ri:secret value="x">`,
770+
match: `secret`,
771+
want: false,
772+
},
773+
{
774+
name: "no match when text is outside any tag",
775+
ruleID: rules.GenericApiKeyID,
776+
line: `ri:secret=12345`,
777+
match: `secret=12345`,
778+
want: false,
779+
},
780+
{
781+
name: "no match for xri: prefixed attribute",
782+
ruleID: rules.GenericApiKeyID,
783+
line: `<ri:attachment xri:secret="12345" />`,
784+
match: `secret="12345"`,
785+
want: false,
786+
},
787+
{
788+
name: "no match when rule ID is not generic-api-key does not apply",
789+
ruleID: "some-other-rule",
790+
line: `<ri:attachment ri:secret="12345" />`,
791+
match: `secret="12345"`,
792+
want: false,
793+
},
794+
}
795+
796+
for _, tt := range tests {
797+
t.Run(tt.name, func(t *testing.T) {
798+
got := isSecretFromConfluenceResourceIdentifier(tt.ruleID, tt.line, tt.match)
799+
assert.Equal(t, tt.want, got, "ruleID=%q, line=%q, match=%q", tt.ruleID, tt.line, tt.match)
800+
})
801+
}
802+
}
803+
804+
// if any of these tests fails, we should review isSecretFromConfluenceResourceIdentifier and/or generic-api-key rule
805+
func TestDetectWithConfluenceMetadata(t *testing.T) {
806+
secretsCases := []struct {
807+
Content string
808+
Name string
809+
ShouldFind bool
810+
}{
811+
{
812+
Content: "<ri:user ri:userkey=\"8a7f808362ce64321162ceb20e64321a\" >",
813+
Name: "should not detect from confluence userkey metadata",
814+
ShouldFind: false,
815+
},
816+
}
817+
818+
detector, err := Init(&EngineConfig{})
819+
if err != nil {
820+
t.Fatal(err)
821+
}
822+
823+
for _, secret := range secretsCases {
824+
t.Run(secret.Name, func(t *testing.T) {
825+
secretsChan := make(chan *secrets.Secret, 1)
826+
c := plugins.ConfluencePlugin{}
827+
err = detector.DetectFragment(item{content: &secret.Content}, secretsChan, c.GetName())
828+
if err != nil {
829+
return
830+
}
831+
close(secretsChan)
832+
833+
s := <-secretsChan
834+
835+
if secret.ShouldFind {
836+
assert.Equal(t, s.LineContent, secret.Content)
837+
} else {
838+
assert.Nil(t, s)
839+
}
840+
})
841+
}
842+
}
843+
730844
type item struct {
731845
content *string
732846
id string

engine/rules/generic-key.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"github.com/zricethezav/gitleaks/v8/config"
88
)
99

10+
const GenericApiKeyID = "generic-api-key"
11+
1012
func GenericCredential() *config.Rule {
1113
regex := generateSemiGenericRegexIncludingXml([]string{
1214
"access",
@@ -21,7 +23,7 @@ func GenericCredential() *config.Rule {
2123
}, `[\w.=-]{10,150}|[a-z0-9][a-z0-9+/]{11,}={0,3}`, true)
2224

2325
return &config.Rule{
24-
RuleID: "generic-api-key",
26+
RuleID: GenericApiKeyID,
2527
Description: "Detected a Generic API Key, potentially exposing access to various services and sensitive operations.",
2628
Regex: regex,
2729
Keywords: []string{

0 commit comments

Comments
 (0)