mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
15 Commits
feat/chart
...
v1.0.73-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b704a495de | ||
|
|
72fe82d70d | ||
|
|
683a721a76 | ||
|
|
bbb3c505e0 | ||
|
|
e50820bd11 | ||
|
|
ac1e09e46f | ||
|
|
2bfde8d886 | ||
|
|
7b989948c4 | ||
|
|
964c571063 | ||
|
|
4a523b12f2 | ||
|
|
c6039a923c | ||
|
|
6ff10229fd | ||
|
|
21cff2e2dd | ||
|
|
15e4175986 | ||
|
|
12b7f7a0cd |
145
.github/workflows/release.yml
vendored
145
.github/workflows/release.yml
vendored
@@ -9,10 +9,54 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
REHEARSAL_BRANCH: test/npm-staged-publish-rehearsal
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TAG" == *-beta.* ]]; then
|
||||
git fetch origin "$REHEARSAL_BRANCH"
|
||||
REHEARSAL_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
if [[ "$HEAD_SHA" != "$REHEARSAL_SHA" ]]; then
|
||||
echo "Beta rehearsal tag ${TAG} must point to the current origin/${REHEARSAL_BRANCH} commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
git fetch origin main
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
build-stage-assets:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -26,35 +70,86 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: release --clean --skip=publish
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Verify release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
test -s dist/checksums.txt
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Publish to npm
|
||||
- name: Pack npm tarball
|
||||
id: pack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
echo "filename=$PACK_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Collect rehearsal assets
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
PACK_FILE: ${{ steps.pack.outputs.filename }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir staged-release-assets
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt "$PACK_FILE" staged-release-assets/
|
||||
|
||||
- name: Upload rehearsal artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: staged-release-assets-${{ github.run_id }}
|
||||
path: staged-release-assets/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
stage-publish:
|
||||
needs: build-stage-assets
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download rehearsal artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: staged-release-assets-${{ github.run_id }}
|
||||
path: staged-release-assets
|
||||
|
||||
- name: Verify rehearsal asset
|
||||
id: asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd staged-release-assets && sha256sum --check checksums.txt)
|
||||
PACK_FILE="$(find staged-release-assets -maxdepth 1 -type f -name '*.tgz' -print -quit)"
|
||||
test -n "$PACK_FILE"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
echo "filename=$PACK_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stage npm package
|
||||
run: npm stage publish "${{ steps.asset.outputs.filename }}" --access public --tag beta
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/release-workflow.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
@@ -19,12 +19,18 @@ import (
|
||||
type eventPayload struct {
|
||||
Comment *struct {
|
||||
Body string `json:"body"`
|
||||
Path string `json:"path"`
|
||||
} `json:"comment"`
|
||||
Review *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"review"`
|
||||
}
|
||||
|
||||
type commentContent struct {
|
||||
Body string
|
||||
Path string
|
||||
}
|
||||
|
||||
func main() {
|
||||
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||
@@ -34,12 +40,11 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
body, err := commentBody(*eventPath)
|
||||
diags, err := auditEvent(*eventPath, *kind)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||
if len(diags) > 0 {
|
||||
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||
}
|
||||
@@ -47,32 +52,44 @@ func main() {
|
||||
os.Exit(report.ExitCode(diags))
|
||||
}
|
||||
|
||||
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
|
||||
content, err := commentBody(eventPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCommentContent(kind, content), nil
|
||||
}
|
||||
|
||||
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
|
||||
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
|
||||
}
|
||||
|
||||
func auditFailureSummary(count int) string {
|
||||
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||
}
|
||||
|
||||
func commentBody(path string) (string, error) {
|
||||
func commentBody(path string) (commentContent, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return commentContent{}, err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
return commentContent{}, err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return payload.Comment.Body, nil
|
||||
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
|
||||
case payload.Review != nil:
|
||||
return payload.Review.Body, nil
|
||||
return commentContent{Body: payload.Review.Body}, nil
|
||||
default:
|
||||
return "", nil
|
||||
return commentContent{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
@@ -32,11 +34,92 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
if got.Body != "clean comment" || got.Path != "" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
got, err := commentBody("event.json")
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `CLIENT_SECRET=$(security find-generic-password -w)`
|
||||
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
diags, err := auditEvent("event.json", "pull_request_review_comment")
|
||||
if err != nil {
|
||||
t.Fatalf("auditEvent() error = %v", err)
|
||||
}
|
||||
for _, diag := range diags {
|
||||
if diag.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
|
||||
}
|
||||
}
|
||||
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
|
||||
for _, finding := range pathless {
|
||||
if finding.Rule == "public_content_generic_credential" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
|
||||
}
|
||||
|
||||
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
|
||||
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
|
||||
content := commentContent{
|
||||
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
|
||||
Path: "cmd/agent/list_test.go",
|
||||
}
|
||||
|
||||
diags := scanCommentContent("pull_request_review_comment", content)
|
||||
for _, diag := range diags {
|
||||
if diag.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
if diag.File != content.Path {
|
||||
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing provider credential diagnostic: %#v", diags)
|
||||
}
|
||||
|
||||
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||
|
||||
@@ -23,9 +23,10 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
runGit(t, repo, "add", "baseline.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||
|
||||
api_`+`key = "example-public-key"
|
||||
api_`+`key = "`+providerValue+`"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
@@ -199,13 +200,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_` + `token":"real-json-token"}`,
|
||||
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||
`{"github` + `Token":"real-github-token"}`,
|
||||
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||
`{"access_` + `token":"` + providerValue + `"}`,
|
||||
`{"client_` + `secret": "` + providerValue + `"}`,
|
||||
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
|
||||
`{"github` + `Token":"` + providerValue + `"}`,
|
||||
`{"vendorApi` + `Key":"` + providerValue + `"}`,
|
||||
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
@@ -215,14 +217,7 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -306,8 +301,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
if count != 2 {
|
||||
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,12 +333,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -358,15 +353,11 @@ func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
@@ -374,7 +365,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -391,7 +382,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
if strings.Contains(item.Excerpt, accessKey) {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
@@ -432,7 +423,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -448,15 +439,11 @@ func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
@@ -489,12 +476,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
@@ -506,13 +494,7 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -621,7 +603,8 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
@@ -685,10 +668,11 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
return ScanCommentAtPath(kind, "", body)
|
||||
}
|
||||
|
||||
func ScanCommentAtPath(kind, path, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
return scanText(kind, "comment", body, false)
|
||||
if path == "" {
|
||||
path = kind
|
||||
}
|
||||
return scanText(path, "comment", body, isDetectorRuleFile(path))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
@@ -17,3 +20,60 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" participant Client",
|
||||
" participant AccessTokenHashTransport",
|
||||
" participant SecurityPolicyTransport",
|
||||
" Client->>AccessTokenHashTransport: Send request with bearer token",
|
||||
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
|
||||
" Client -> ClientSecret: Resolve configured credential",
|
||||
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
credentialAssignment := "password=" + providerValue
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" Client->>Server: Send " + credentialAssignment,
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
|
||||
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential in review comment should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
88
internal/qualitygate/publiccontent/credential.go
Normal file
88
internal/qualitygate/publiccontent/credential.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func credentialValueHasStrongEvidence(key, value string) bool {
|
||||
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
|
||||
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
|
||||
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
|
||||
for _, candidate := range candidates {
|
||||
if providerCredentialIdentifier(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if isCredentialMetadataField(key) {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
|
||||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
|
||||
}
|
||||
|
||||
func credentialEvidenceCandidates(value string) []string {
|
||||
candidates := []string{value}
|
||||
for range 3 {
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil || decoded == value {
|
||||
break
|
||||
}
|
||||
candidates = append(candidates, decoded)
|
||||
value = decoded
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func isCredentialMetadataField(key string) bool {
|
||||
if isBenignTokenField(key) {
|
||||
return true
|
||||
}
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
switch parts[len(parts)-1] {
|
||||
case "hash", "id", "kind", "marker", "prefix", "transport":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func base64PaddedCredentialValue(value string) bool {
|
||||
if len(value) < 16 || !strings.HasSuffix(value, "=") {
|
||||
return false
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
|
||||
return false
|
||||
}
|
||||
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
|
||||
}
|
||||
|
||||
func percentEncodedCredentialValue(value string) bool {
|
||||
if len(value) < 16 {
|
||||
return false
|
||||
}
|
||||
var escapes int
|
||||
for i := 0; i+2 < len(value); i++ {
|
||||
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
|
||||
escapes++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return escapes >= 2
|
||||
}
|
||||
|
||||
func isHexByte(value byte) bool {
|
||||
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
|
||||
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||
@@ -383,33 +383,63 @@ func anglePlaceholderIdentifier(value string) bool {
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
return providerCredentialIdentifier(value)
|
||||
}
|
||||
|
||||
func providerCredentialIdentifier(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
switch {
|
||||
case strings.HasPrefix(value, "sk_live_"),
|
||||
strings.HasPrefix(value, "sk_test_"),
|
||||
strings.HasPrefix(value, "ghp_"),
|
||||
strings.HasPrefix(value, "gho_"),
|
||||
strings.HasPrefix(value, "ghu_"),
|
||||
strings.HasPrefix(value, "github_pat_"),
|
||||
strings.HasPrefix(value, "xoxb_"),
|
||||
strings.HasPrefix(value, "xoxp_"),
|
||||
strings.HasPrefix(value, "xoxa_"):
|
||||
return true
|
||||
case strings.HasPrefix(value, "real-") &&
|
||||
(strings.Contains(value, "secret") ||
|
||||
strings.Contains(value, "token") ||
|
||||
strings.Contains(value, "key") ||
|
||||
strings.Contains(value, "password")):
|
||||
case providerTokenWithBody(value, "sk_live_", 16, ""),
|
||||
providerTokenWithBody(value, "sk_test_", 16, ""),
|
||||
providerTokenWithBody(value, "ghp_", 16, ""),
|
||||
providerTokenWithBody(value, "gho_", 16, ""),
|
||||
providerTokenWithBody(value, "ghu_", 16, ""),
|
||||
providerTokenWithBody(value, "github_pat_", 16, "_"),
|
||||
providerTokenWithBody(value, "xoxb_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxb-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa-", 16, "-"),
|
||||
awsAccessKeyIdentifier(value):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
|
||||
body, ok := strings.CutPrefix(value, prefix)
|
||||
if !ok || len(body) < minBodyLength {
|
||||
return false
|
||||
}
|
||||
for _, r := range body {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func awsAccessKeyIdentifier(value string) bool {
|
||||
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[4:] {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceTokenPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
switch normalized {
|
||||
|
||||
@@ -47,15 +47,30 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
|
||||
inPrivateKey = false
|
||||
}
|
||||
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
|
||||
rawMatch := line[location[0]:location[1]]
|
||||
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
|
||||
continue
|
||||
}
|
||||
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
|
||||
if !isCredentialAssignmentMatch(rawMatch) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
|
||||
evidenceValue := value
|
||||
if sourceCodeFile(file) {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
|
||||
evidenceValue = rhs
|
||||
}
|
||||
}
|
||||
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
|
||||
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
|
||||
continue
|
||||
}
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
@@ -64,7 +79,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
if looksLikeEqualityComparison(value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if !isJWTToken(match) {
|
||||
@@ -123,21 +138,43 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
return out
|
||||
}
|
||||
|
||||
func validCredentialAssignmentStart(line string, start int, match string) bool {
|
||||
if start <= 0 || credentialAssignmentOperator(match) != ":" {
|
||||
return true
|
||||
}
|
||||
prefix := strings.TrimSpace(line[:start])
|
||||
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
|
||||
if strings.HasSuffix(prefix, arrow) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialAssignmentOperator(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(match[len(key):])
|
||||
if strings.HasPrefix(rest, ":=") {
|
||||
return ":="
|
||||
}
|
||||
if strings.HasPrefix(rest, ":") {
|
||||
return ":"
|
||||
}
|
||||
if strings.HasPrefix(rest, "=") {
|
||||
return "="
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCredentialAssignmentMatch(match string) bool {
|
||||
name, value, ok := normalizedCredentialAssignment(match)
|
||||
name, _, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
@@ -288,7 +325,7 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
@@ -323,52 +360,8 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
@@ -488,17 +481,20 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
if !sourceCodeFile(file) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
@@ -518,17 +514,16 @@ func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
|
||||
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
rest := strings.TrimSpace(line[matchStart+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
@@ -536,7 +531,12 @@ func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
|
||||
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
|
||||
if parsed == nil {
|
||||
return rhs, true
|
||||
}
|
||||
return credentialAssignmentValue(parsed), true
|
||||
}
|
||||
|
||||
func isBenignTypedCredentialRHS(value string) bool {
|
||||
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -593,6 +593,7 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeStringExpressionLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
@@ -685,6 +686,18 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeStringExpressionLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" ||
|
||||
credentialShapedIdentifier(normalized) ||
|
||||
highEntropyCredentialValue(strings.ToLower(normalized)) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(normalized, "${") ||
|
||||
strings.Contains(normalized, "$(") ||
|
||||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
@@ -753,7 +766,7 @@ func codeIdentifier(value string) bool {
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
case "true", "false", "null", "nil", "{", "[", `\`:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -980,6 +993,7 @@ func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"p%40ss",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
|
||||
@@ -251,26 +251,22 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"API_KEY=notredactedreal",
|
||||
"API_KEY=notplaceholdersecret",
|
||||
"API_KEY=abcxxxxreal",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable credential words should not be findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
|
||||
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
|
||||
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
|
||||
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
|
||||
paddedToken := base64PaddedFixture(paddedTokenPrefix)
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
@@ -294,17 +290,25 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
|
||||
if findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
jsonToken := "real-json-token"
|
||||
jsonSecret := "real " + "secret value"
|
||||
jsonKey := "real-json-key"
|
||||
jsonTenantToken := "real-tenant-json-token"
|
||||
jsonAppSecret := "real-app-secret"
|
||||
jsonPrefixedKey := "real-prefixed-key"
|
||||
jsonTenantCamelToken := "real-tenant-camel-token"
|
||||
jsonGithubToken := "real-github-token"
|
||||
jsonVendorKey := "real-vendor-key"
|
||||
jsonSlackBotToken := "xoxb-real-token"
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
jsonToken := providerValue
|
||||
jsonSecret := providerValue
|
||||
jsonKey := providerValue
|
||||
jsonTenantToken := providerValue
|
||||
jsonAppSecret := providerValue
|
||||
jsonPrefixedKey := providerValue
|
||||
jsonTenantCamelToken := providerValue
|
||||
jsonGithubToken := providerValue
|
||||
jsonVendorKey := providerValue
|
||||
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_` + `token":"` + jsonToken + `"}`,
|
||||
`{"client_` + `secret": "` + jsonSecret + `"}`,
|
||||
@@ -334,12 +338,13 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -347,13 +352,7 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -364,85 +363,77 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: prod_key",
|
||||
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||
"TOKEN_GITHUB: github_token",
|
||||
"APP_PASSWORD_PROD: prod_password",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY: <" + stripeLike + ">",
|
||||
"SECRET_TOKEN: <" + patLike + ">",
|
||||
"CLIENT_SECRET: <real-client-secret-value>",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
|
||||
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
|
||||
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||
`{"client_secret_status":"real-client-secret-value"}`,
|
||||
`{"client_secret_name":"real-client-secret-value"}`,
|
||||
`{"app_token":"` + patLike + `"}`,
|
||||
`{"sync_token":"` + stripeLike + `"}`,
|
||||
`{"target_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
|
||||
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
|
||||
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
|
||||
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
|
||||
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
|
||||
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
|
||||
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_NAME: prod_key",
|
||||
"CLIENT_SECRET_NAME: prod_secret",
|
||||
"SECRET_STATUS: prod_secret",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -593,18 +584,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY=${{" + stripeLike + "}}",
|
||||
"TOKEN=${{real-secret-token-value}}",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
|
||||
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +639,7 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`proxy := "http://user:p%40ss@proxy:8080/path"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
@@ -821,26 +813,36 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("token field names alone should not produce findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
|
||||
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
|
||||
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
|
||||
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
|
||||
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
|
||||
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
|
||||
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
|
||||
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
|
||||
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
@@ -848,8 +850,114 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "k1",`,
|
||||
`"secret_id": "s1",`,
|
||||
`"token_id": "t1",`,
|
||||
`"private_key_id": "pk1",`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "` + stripeLike + `",`,
|
||||
`"token_id": "` + githubToken + `",`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
if !credentialShapedValue(` "` + providerValue + `" `) {
|
||||
t.Fatal("space-padded quoted provider credential should be recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
}{
|
||||
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
|
||||
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
|
||||
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
|
||||
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
|
||||
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
|
||||
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential should be reported: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
|
||||
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []string{
|
||||
"access_token: ghp%" + "5F" + providerBody,
|
||||
"access_token_hash: ghp%" + "255F" + providerBody,
|
||||
}
|
||||
for _, text := range tests {
|
||||
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"token_type: asian",
|
||||
"token_prefix: ASIA",
|
||||
"token_prefix: ghp_",
|
||||
"api_key: sk_live_example",
|
||||
"token_prefix: asianmarketsegment01",
|
||||
"token_prefix: ghp_placeholder_value",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
|
||||
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
|
||||
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -927,6 +1035,22 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`const localOfficeTokenPrefix = "local_office_"`,
|
||||
`const imageLiveSecretMarker = "img_live_secret"`,
|
||||
`const imageProdKeyMarker = "img_prod_key"`,
|
||||
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
|
||||
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
@@ -941,22 +1065,18 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
||||
"client_secret=realprefix***realsuffix",
|
||||
"client_secret=ab********cd",
|
||||
"access_token=ab********cd",
|
||||
"refresh_token=realprefix********realsuffix",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("partially masked values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
@@ -972,6 +1092,7 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
@@ -980,32 +1101,47 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
text: `const clientSecret: string = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "typescript numeric password",
|
||||
name: "typescript terminated secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
text: `const clientSecret: string = "` + providerValue + `";`,
|
||||
},
|
||||
{
|
||||
name: "typescript secret with trailing comment",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `"; // production`,
|
||||
},
|
||||
{
|
||||
name: "typescript asserted secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `" as const;`,
|
||||
},
|
||||
{
|
||||
name: "typescript provider password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
text: `self.client_secret: str = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
text: `self.client_secret: str | None = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -1018,24 +1154,154 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`const ClientSecret = "real-client-secret-value"`,
|
||||
`const GithubToken = "` + githubToken + `"`,
|
||||
`const Password = "12345678901234567890"`,
|
||||
`const ClientSecretNumber = "12345678901234567890"`,
|
||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
||||
}, "\n")+"\n"))
|
||||
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
|
||||
|
||||
got := ScanFile("fixtures/source_secret.ts", []byte(
|
||||
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
|
||||
))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
if count != 1 {
|
||||
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
|
||||
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
|
||||
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
|
||||
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
|
||||
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
|
||||
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
|
||||
providerSecret := "sk_" + "live_1234567890abcdef"
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`clientSecret := "` + providerSecret + `"`,
|
||||
`accessToken := "` + providerToken + `"`,
|
||||
}, "\n")+"\n"))
|
||||
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericCredentialDecisionMatrix(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
|
||||
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
comment bool
|
||||
want bool
|
||||
}{
|
||||
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
|
||||
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
|
||||
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
|
||||
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
|
||||
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
|
||||
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
|
||||
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
|
||||
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
|
||||
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
|
||||
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
|
||||
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
|
||||
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
|
||||
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
|
||||
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
|
||||
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
|
||||
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
|
||||
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
|
||||
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
|
||||
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
|
||||
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got []Finding
|
||||
if tt.comment {
|
||||
got = ScanComment("issue_comment", tt.text)
|
||||
} else {
|
||||
got = ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
}
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{name: "human readable access token", value: "user-access-token", want: false},
|
||||
{name: "delimited secret value", value: "secret-value", want: false},
|
||||
{name: "underscored secret fixture", value: "plain_secret", want: false},
|
||||
{name: "short delimited fixture", value: "t-abc", want: false},
|
||||
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
|
||||
{name: "real labeled fixture", value: "real-token", want: false},
|
||||
{name: "ambiguous concrete word", value: "supersecret", want: false},
|
||||
{name: "provider token", value: providerToken, want: true},
|
||||
{name: "high entropy secret", value: highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
|
||||
`secret := "secret-value"`,
|
||||
`samplePassword := "sample-password"`,
|
||||
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1116,9 +1382,10 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"` + stripeLike + `"}`,
|
||||
`{"client_token":"real-client-secret-value"}`,
|
||||
`{"client_token":"` + githubToken + `"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1152,9 +1419,10 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "resource_token": "` + stripeLike + `" }`,
|
||||
`{ "block_token": "real-client-secret-value" }`,
|
||||
`{ "block_token": "` + githubToken + `" }`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1368,39 +1636,43 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
||||
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: " + stripeLike + "_HERE",
|
||||
"api_key: YOUR_" + stripeLike,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"CLIENT_SECRET=%" + stripeLike + "%",
|
||||
"GITHUB_TOKEN=%" + patLike + "%",
|
||||
"TOKEN=%real-secret-token-value%",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
|
||||
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
|
||||
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
|
||||
t.Helper()
|
||||
got := ScanFile(file, []byte(text+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +203,8 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
|
||||
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.73-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.73-beta.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.72",
|
||||
"version": "1.0.73-beta.0",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js"
|
||||
"postinstall": "node scripts/install.js",
|
||||
"release:check": "node scripts/release-preflight.js"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
|
||||
@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
);
|
||||
return null;
|
||||
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (expectedHash === null) return;
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error("[SECURITY] Expected checksum is missing or invalid");
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
|
||||
throw new Error(
|
||||
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
{ message: /^\[SECURITY\] checksums\.txt not found/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
it("accepts a valid uppercase 64-character hex hash", () => {
|
||||
const content = "case test";
|
||||
const filePath = makeTmpFile(content);
|
||||
const hash = sha256(content).toUpperCase();
|
||||
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
for (const [name, expectedHash] of [
|
||||
["null", null],
|
||||
["empty", ""],
|
||||
["non-string", 123],
|
||||
]) {
|
||||
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /Expected checksum is missing or invalid/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "abc123"),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY] format Error for a non-hex hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "g".repeat(64)),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY]-prefixed Error on mismatch", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
|
||||
110
scripts/release-preflight.js
Normal file
110
scripts/release-preflight.js
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
||||
const REHEARSAL_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$/;
|
||||
|
||||
function isReleaseVersion(value) {
|
||||
return typeof value === "string" &&
|
||||
(STABLE_VERSION_PATTERN.test(value) || REHEARSAL_VERSION_PATTERN.test(value));
|
||||
}
|
||||
|
||||
function releaseError(message, observed, hint) {
|
||||
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
|
||||
}
|
||||
|
||||
function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
const packageVersion = packageJson?.version;
|
||||
const lockVersion = packageLockJson?.version;
|
||||
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
|
||||
const observed = {
|
||||
packageVersion: packageVersion ?? null,
|
||||
lockVersion: lockVersion ?? null,
|
||||
lockRootVersion: lockRootVersion ?? null,
|
||||
tagVersion: null,
|
||||
};
|
||||
|
||||
for (const [field, value] of [
|
||||
["package.json.version", packageVersion],
|
||||
["package-lock.json.version", lockVersion],
|
||||
['package-lock.json.packages[""].version', lockRootVersion],
|
||||
]) {
|
||||
if (!isReleaseVersion(value)) {
|
||||
return releaseError(
|
||||
`${field} must use X.Y.Z or the rehearsal form X.Y.Z-beta.N`,
|
||||
observed,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
|
||||
return releaseError(
|
||||
"Package version fields do not match",
|
||||
observed,
|
||||
"Synchronize package.json.version and both package-lock.json version fields.",
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === undefined) {
|
||||
return { ok: true, data: observed };
|
||||
}
|
||||
if (typeof tag !== "string" || !tag.startsWith("v") || !isReleaseVersion(tag.slice(1))) {
|
||||
return releaseError(
|
||||
"--tag must use vX.Y.Z or the rehearsal form vX.Y.Z-beta.N",
|
||||
{ ...observed, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const tagVersion = tag.slice(1);
|
||||
if (tagVersion !== packageVersion) {
|
||||
return releaseError(
|
||||
"Tag version does not match the package version",
|
||||
{ ...observed, tagVersion, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, data: { ...observed, tagVersion } };
|
||||
}
|
||||
|
||||
function writeResult(result) {
|
||||
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let tag;
|
||||
if (args.length === 2 && args[0] === "--tag") {
|
||||
tag = args[1];
|
||||
} else if (args.length !== 0) {
|
||||
writeResult(releaseError(
|
||||
"Expected no arguments or --tag vX.Y.Z",
|
||||
{ arguments: args },
|
||||
"Run release:check without arguments or pass exactly one --tag value.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
|
||||
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
} catch (error) {
|
||||
writeResult(releaseError(
|
||||
"Could not read release package metadata",
|
||||
{ reason: error.message },
|
||||
"Ensure package.json and package-lock.json exist and contain valid JSON.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateReleasePreflight };
|
||||
|
||||
if (require.main === module) main();
|
||||
611
scripts/release-preflight.test.js
Normal file
611
scripts/release-preflight.test.js
Normal file
@@ -0,0 +1,611 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const {
|
||||
validateReleasePreflight,
|
||||
} = require("./release-preflight");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function createReleaseFixture(t, env = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tag-release-test-"));
|
||||
const scriptsDir = path.join(root, "scripts");
|
||||
const binDir = path.join(root, "bin");
|
||||
const stateDir = path.join(root, "state");
|
||||
const logPath = path.join(root, "git-calls.jsonl");
|
||||
const npmLogPath = path.join(root, "npm-calls.jsonl");
|
||||
fs.mkdirSync(scriptsDir);
|
||||
fs.mkdirSync(binDir);
|
||||
fs.mkdirSync(stateDir);
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, "scripts/release-preflight.js"),
|
||||
path.join(scriptsDir, "release-preflight.js"),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, "scripts/tag-release.sh"),
|
||||
path.join(scriptsDir, "tag-release.sh"),
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "package.json"), '{"version":"1.2.3-beta.0"}\n');
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package-lock.json"),
|
||||
'{"version":"1.2.3-beta.0","packages":{"":{"version":"1.2.3-beta.0"}}}\n',
|
||||
);
|
||||
|
||||
const fakeGitPath = path.join(binDir, "git");
|
||||
fs.writeFileSync(fakeGitPath, String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const stateDir = process.env.FAKE_GIT_STATE_DIR;
|
||||
const localTagPath = path.join(stateDir, "local-tag");
|
||||
if (process.cwd() !== process.env.FAKE_EXPECTED_GIT_CWD) {
|
||||
process.stderr.write("git invoked outside repository root: " + process.cwd() + "\n");
|
||||
process.exit(96);
|
||||
}
|
||||
fs.appendFileSync(process.env.FAKE_GIT_LOG, JSON.stringify(args) + "\n");
|
||||
|
||||
function print(value) {
|
||||
process.stdout.write(value + "\n");
|
||||
}
|
||||
|
||||
switch (args[0]) {
|
||||
case "branch":
|
||||
print(process.env.FAKE_BRANCH || "test/npm-staged-publish-rehearsal");
|
||||
break;
|
||||
case "status":
|
||||
if (process.env.FAKE_STATUS_OUTPUT) print(process.env.FAKE_STATUS_OUTPUT);
|
||||
break;
|
||||
case "fetch":
|
||||
break;
|
||||
case "rev-parse": {
|
||||
const ref = args[args.length - 1];
|
||||
if (ref === "HEAD") {
|
||||
print(process.env.FAKE_HEAD_SHA);
|
||||
break;
|
||||
}
|
||||
if (ref === "FETCH_HEAD^{commit}") {
|
||||
print(process.env.FAKE_REHEARSAL_SHA);
|
||||
break;
|
||||
}
|
||||
if (ref.startsWith("refs/tags/")) {
|
||||
if (fs.existsSync(localTagPath)) {
|
||||
print(fs.readFileSync(localTagPath, "utf8").trim());
|
||||
break;
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write("unexpected rev-parse ref: " + ref + "\n");
|
||||
process.exit(97);
|
||||
break;
|
||||
}
|
||||
case "ls-remote": {
|
||||
const tagRef = args.find((arg) => arg.startsWith("refs/tags/") && !arg.endsWith("^{}"));
|
||||
const kind = process.env.FAKE_REMOTE_TAG_KIND || "absent";
|
||||
if (kind === "lightweight" || kind === "annotated") {
|
||||
print(process.env.FAKE_REMOTE_TAG_SHA + "\t" + tagRef);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "show":
|
||||
print(process.env.FAKE_WORKFLOW);
|
||||
break;
|
||||
case "tag":
|
||||
fs.writeFileSync(localTagPath, args[2] || process.env.FAKE_HEAD_SHA);
|
||||
break;
|
||||
case "push": {
|
||||
const failedMarker = path.join(stateDir, "push-failed");
|
||||
if (process.env.FAKE_PUSH_FAIL_ONCE && !fs.existsSync(failedMarker)) {
|
||||
fs.writeFileSync(failedMarker, "1");
|
||||
process.exit(Number(process.env.FAKE_PUSH_FAIL_ONCE));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
process.stderr.write("unexpected git command: " + args.join(" ") + "\n");
|
||||
process.exit(97);
|
||||
}
|
||||
`);
|
||||
fs.chmodSync(fakeGitPath, 0o755);
|
||||
|
||||
const fakeNpmPath = path.join(binDir, "npm");
|
||||
fs.writeFileSync(fakeNpmPath, String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\n");
|
||||
if (args[0] !== "view") {
|
||||
process.stderr.write("unexpected npm command: " + args.join(" ") + "\n");
|
||||
process.exit(97);
|
||||
}
|
||||
const output = process.env.FAKE_NPM_VIEW_OUTPUT || "npm error code E404\nnpm error 404 Not Found";
|
||||
(Number(process.env.FAKE_NPM_VIEW_STATUS || "1") === 0 ? process.stdout : process.stderr).write(output + "\n");
|
||||
process.exit(Number(process.env.FAKE_NPM_VIEW_STATUS || "1"));
|
||||
`);
|
||||
fs.chmodSync(fakeNpmPath, 0o755);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
return {
|
||||
root,
|
||||
stateDir,
|
||||
logPath,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
||||
LANG: "C",
|
||||
LC_ALL: "C",
|
||||
FAKE_GIT_LOG: logPath,
|
||||
FAKE_NPM_LOG: npmLogPath,
|
||||
FAKE_GIT_STATE_DIR: stateDir,
|
||||
FAKE_EXPECTED_GIT_CWD: fs.realpathSync(root),
|
||||
FAKE_HEAD_SHA: "aaaaaaaa",
|
||||
FAKE_REHEARSAL_SHA: "aaaaaaaa",
|
||||
FAKE_WORKFLOW: "args: release --clean --skip=publish\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
...env,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runTagRelease(fixture, options = {}) {
|
||||
const { cwd = fixture.root, args = [], input = "" } = options;
|
||||
return spawnSync("bash", [path.join(fixture.root, "scripts/tag-release.sh"), ...args], {
|
||||
cwd,
|
||||
env: fixture.env,
|
||||
encoding: "utf8",
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
function readGitCalls(fixture) {
|
||||
if (!fs.existsSync(fixture.logPath)) {
|
||||
return [];
|
||||
}
|
||||
return fs.readFileSync(fixture.logPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function assertNoTagOperations(calls) {
|
||||
const tagOperations = calls.filter((args) =>
|
||||
args[0] === "ls-remote" ||
|
||||
args[0] === "tag" ||
|
||||
args[0] === "push" ||
|
||||
(args[0] === "rev-parse" && args.some((arg) => arg.startsWith("refs/tags/"))),
|
||||
);
|
||||
assert.deepEqual(tagOperations, []);
|
||||
}
|
||||
|
||||
function assertNoTagWrites(calls) {
|
||||
assert.equal(calls.some((args) => args[0] === "tag" || args[0] === "push"), false);
|
||||
}
|
||||
|
||||
function validInputs(version = "1.2.3") {
|
||||
return {
|
||||
packageJson: { version },
|
||||
packageLockJson: {
|
||||
version,
|
||||
packages: { "": { version } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertStructuredError(result) {
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.type, "release_preflight");
|
||||
assert.equal(typeof result.error.message, "string");
|
||||
assert.ok(result.error.message.length > 0);
|
||||
assert.equal(typeof result.error.observed, "object");
|
||||
assert.equal(typeof result.error.hint, "string");
|
||||
assert.ok(result.error.hint.length > 0);
|
||||
}
|
||||
|
||||
function assertInOrder(source, snippets) {
|
||||
let previous = -1;
|
||||
for (const snippet of snippets) {
|
||||
const index = source.indexOf(snippet);
|
||||
assert.ok(index >= 0, `missing fragment: ${snippet}`);
|
||||
assert.ok(index > previous, `fragment is out of order: ${snippet}`);
|
||||
previous = index;
|
||||
}
|
||||
}
|
||||
|
||||
describe("validateReleasePreflight", () => {
|
||||
it("accepts matching stable and beta rehearsal versions", () => {
|
||||
for (const version of ["1.2.3", "1.2.3-beta.0"]) {
|
||||
const { packageJson, packageLockJson } = validInputs(version);
|
||||
|
||||
assert.deepEqual(validateReleasePreflight(packageJson, packageLockJson), {
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: version,
|
||||
lockVersion: version,
|
||||
lockRootVersion: version,
|
||||
tagVersion: null,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
validateReleasePreflight(packageJson, packageLockJson, `v${version}`),
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: version,
|
||||
lockVersion: version,
|
||||
lockRootVersion: version,
|
||||
tagVersion: version,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects prerelease forms other than beta rehearsal versions", () => {
|
||||
const { packageJson, packageLockJson } = validInputs("1.2.3-rc.1");
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.equal(
|
||||
result.error.message,
|
||||
"package.json.version must use X.Y.Z or the rehearsal form X.Y.Z-beta.N",
|
||||
);
|
||||
assert.equal(
|
||||
result.error.hint,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects build metadata package versions with the stable release contract", () => {
|
||||
const { packageJson, packageLockJson } = validInputs("1.2.3+build.7");
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.equal(
|
||||
result.error.message,
|
||||
"package.json.version must use X.Y.Z or the rehearsal form X.Y.Z-beta.N",
|
||||
);
|
||||
assert.equal(
|
||||
result.error.hint,
|
||||
"Use the same version in all package fields; only stable releases and the temporary beta rehearsal form are allowed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid and missing package or lock SemVer values", () => {
|
||||
const invalid = validInputs();
|
||||
invalid.packageJson.version = "01.2.3";
|
||||
const missing = validInputs();
|
||||
delete missing.packageLockJson.packages[""].version;
|
||||
|
||||
for (const result of [
|
||||
validateReleasePreflight(invalid.packageJson, invalid.packageLockJson),
|
||||
validateReleasePreflight(missing.packageJson, missing.packageLockJson),
|
||||
]) {
|
||||
assertStructuredError(result);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a top-level package-lock version mismatch", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
packageLockJson.version = "1.2.4";
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.deepEqual(result.error.observed, {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.4",
|
||||
lockRootVersion: "1.2.3",
|
||||
tagVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a package-lock root package version mismatch", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
packageLockJson.packages[""].version = "1.2.4";
|
||||
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson);
|
||||
|
||||
assertStructuredError(result);
|
||||
assert.deepEqual(result.error.observed, {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.3",
|
||||
lockRootVersion: "1.2.4",
|
||||
tagVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid and mismatched tags", () => {
|
||||
const { packageJson, packageLockJson } = validInputs();
|
||||
|
||||
for (const tag of ["1.2.3", "v01.2.3", "v1.2.4"]) {
|
||||
const result = validateReleasePreflight(packageJson, packageLockJson, tag);
|
||||
assertStructuredError(result);
|
||||
assert.equal(result.error.observed.tag, tag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("release configuration", () => {
|
||||
it("writes success to stdout and structured failures to stderr", () => {
|
||||
const scriptPath = path.join(repoRoot, "scripts/release-preflight.js");
|
||||
const packageVersion = require(path.join(repoRoot, "package.json")).version;
|
||||
const success = spawnSync(process.execPath, [scriptPath, "--tag", `v${packageVersion}`], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const failure = spawnSync(process.execPath, [scriptPath, "--tag", "invalid"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
assert.equal(success.status, 0);
|
||||
assert.equal(success.stderr, "");
|
||||
assert.deepEqual(JSON.parse(success.stdout), {
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion,
|
||||
lockVersion: packageVersion,
|
||||
lockRootVersion: packageVersion,
|
||||
tagVersion: packageVersion,
|
||||
},
|
||||
});
|
||||
assert.equal(failure.status, 1);
|
||||
assert.equal(failure.stdout, "");
|
||||
assertStructuredError(JSON.parse(failure.stderr));
|
||||
});
|
||||
|
||||
it("keeps package metadata synchronized without changing the Node engine", () => {
|
||||
const packageJson = require(path.join(repoRoot, "package.json"));
|
||||
const packageLockJson = require(path.join(repoRoot, "package-lock.json"));
|
||||
|
||||
assert.equal(packageJson.scripts["release:check"], "node scripts/release-preflight.js");
|
||||
assert.equal(packageJson.engines.node, ">=16");
|
||||
assert.equal(packageLockJson.version, packageJson.version);
|
||||
assert.equal(packageLockJson.packages[""].version, packageJson.version);
|
||||
});
|
||||
|
||||
it("runs every release gate before any tag query, creation, or push", () => {
|
||||
const script = fs.readFileSync(path.join(repoRoot, "scripts/tag-release.sh"), "utf8");
|
||||
const preflight = script.indexOf('node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"');
|
||||
const requiredGates = [
|
||||
'CURRENT_BRANCH=$(git branch --show-current)',
|
||||
'git status --porcelain',
|
||||
'git fetch origin "${REHEARSAL_BRANCH}"',
|
||||
'git rev-parse "FETCH_HEAD^{commit}"',
|
||||
'git show "${HEAD_SHA}:.github/workflows/release.yml"',
|
||||
'npm view "@larksuite/cli@${VERSION}" version',
|
||||
];
|
||||
const tagOperations = [
|
||||
'git rev-parse -q --verify "refs/tags/${TAG}"',
|
||||
'git ls-remote --tags origin "refs/tags/${TAG}"',
|
||||
'git tag "${TAG}" "${HEAD_SHA}"',
|
||||
'git push origin "refs/tags/${TAG}:refs/tags/${TAG}"',
|
||||
];
|
||||
|
||||
assert.ok(preflight >= 0, "release preflight invocation is missing");
|
||||
assertInOrder(script, [
|
||||
'REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"',
|
||||
'cd "${REPO_ROOT}"',
|
||||
'node "${SCRIPT_DIR}/release-preflight.js"',
|
||||
]);
|
||||
assert.equal(script.includes("require('${REPO_ROOT}/package.json')"), false);
|
||||
for (const gate of requiredGates) {
|
||||
const index = script.indexOf(gate);
|
||||
assert.ok(index >= 0, `required release gate is missing: ${gate}`);
|
||||
assert.ok(index < script.indexOf(tagOperations[0]), `${gate} must run before tag queries`);
|
||||
}
|
||||
for (const operation of tagOperations) {
|
||||
const index = script.indexOf(operation);
|
||||
assert.ok(index >= 0, `tag operation is missing: ${operation}`);
|
||||
assert.ok(preflight < index, `preflight must run before: ${operation}`);
|
||||
}
|
||||
assertInOrder(script, [
|
||||
'if [ "${PUSH_TAG}" != true ]',
|
||||
'read -r CONFIRM_TAG',
|
||||
'git tag "${TAG}" "${HEAD_SHA}"',
|
||||
'git push origin "refs/tags/${TAG}:refs/tags/${TAG}"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tag-release.sh behavior", () => {
|
||||
it("runs repository checks from the script repository when invoked elsewhere", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "tag-release-cwd-"));
|
||||
t.after(() => fs.rmSync(outside, { recursive: true, force: true }));
|
||||
|
||||
const result = runTagRelease(fixture, { cwd: outside });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
it("rejects a non-rehearsal branch before querying or modifying tags", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_BRANCH: "feature/release" });
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /must be created from test\/npm-staged-publish-rehearsal/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("rejects a dirty working tree before tag operations", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_STATUS_OUTPUT: " M README.md" });
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /working tree must be clean/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("rejects HEAD that differs from the fetched rehearsal branch", (t) => {
|
||||
const fixture = createReleaseFixture(t, { FAKE_REHEARSAL_SHA: "bbbbbbbb" });
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /HEAD must exactly match origin\/test\/npm-staged-publish-rehearsal/i);
|
||||
assertNoTagOperations(calls);
|
||||
});
|
||||
|
||||
it("compares HEAD with the exact fetched rehearsal commit", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(calls.some((args) => args.join(" ") === "fetch origin test/npm-staged-publish-rehearsal"));
|
||||
assert.ok(calls.some((args) => args.join(" ") === "rev-parse FETCH_HEAD^{commit}"));
|
||||
assert.equal(calls.some((args) => args.includes("origin/test/npm-staged-publish-rehearsal")), false);
|
||||
});
|
||||
|
||||
it("fails when the local tag already exists", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.stateDir, "local-tag"), "bbbbbbbb");
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /local tag .* already exists/i);
|
||||
assert.equal(calls.some((args) => args[0] === "ls-remote"), false);
|
||||
assert.equal(calls.some((args) => args[0] === "push"), false);
|
||||
});
|
||||
|
||||
it("fails when a lightweight or annotated remote tag already exists", (t) => {
|
||||
for (const kind of ["lightweight", "annotated"]) {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_REMOTE_TAG_KIND: kind,
|
||||
FAKE_REMOTE_TAG_SHA: "aaaaaaaa",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 1, `${kind}: ${result.stderr}`);
|
||||
assert.match(result.stderr, /remote tag .* already exists/i);
|
||||
assert.equal(calls.some((args) => args[0] === "tag"), false);
|
||||
assert.equal(calls.some((args) => args[0] === "push"), false);
|
||||
}
|
||||
});
|
||||
|
||||
it("check mode completes without creating or pushing a tag", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /No tag was created or pushed/);
|
||||
assertNoTagWrites(calls);
|
||||
});
|
||||
|
||||
it("rejects a production version before invoking git", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.root, "package.json"), '{"version":"1.2.3"}\n');
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.root, "package-lock.json"),
|
||||
'{"version":"1.2.3","packages":{"":{"version":"1.2.3"}}}\n',
|
||||
);
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /require an X\.Y\.Z-beta\.N version/);
|
||||
assert.deepEqual(readGitCalls(fixture), []);
|
||||
});
|
||||
|
||||
it("rejects a workflow that can publish live", (t) => {
|
||||
for (const workflow of [
|
||||
"args: release --clean --skip=publish\nrun: npm publish --access public",
|
||||
"args: release --clean --skip=publish\nrun: npm stage publish package.tgz --access public --tag beta\nrun: gh release create v1.2.3-beta.0",
|
||||
"args: release --clean --skip=publish\npermissions:\n contents: write\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
"args: release --clean --skip=publish\nenv:\n GITHUB_TOKEN: ${{ github.token }}\nrun: npm stage publish package.tgz --access public --tag beta",
|
||||
]) {
|
||||
const fixture = createReleaseFixture(t, { FAKE_WORKFLOW: workflow });
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /must be stage-only/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when npm cannot prove that the version is unused", (t) => {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_NPM_VIEW_OUTPUT: "npm error code ETIMEDOUT",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /npm version lookup failed/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("rejects an existing npm version", (t) => {
|
||||
const fixture = createReleaseFixture(t, {
|
||||
FAKE_NPM_VIEW_STATUS: "0",
|
||||
FAKE_NPM_VIEW_OUTPUT: "1.2.3-beta.0",
|
||||
});
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /already exists on npm/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("requires the full tag confirmation in push mode", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture, { args: ["--push"], input: "no\n" });
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /confirmation did not exactly match/i);
|
||||
assertNoTagWrites(readGitCalls(fixture));
|
||||
});
|
||||
|
||||
it("pushes only the exact confirmed tag ref", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
|
||||
const result = runTagRelease(fixture, {
|
||||
args: ["--push"],
|
||||
input: "v1.2.3-beta.0\n",
|
||||
});
|
||||
const calls = readGitCalls(fixture);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(calls.some((args) => args.join(" ") === "tag v1.2.3-beta.0 aaaaaaaa"));
|
||||
assert.ok(calls.some((args) =>
|
||||
args.join(" ") === "push origin refs/tags/v1.2.3-beta.0:refs/tags/v1.2.3-beta.0"));
|
||||
assert.equal(
|
||||
calls.some((args) => args[0] === "push" && args.includes("--tags")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an invalid package version before invoking git", (t) => {
|
||||
const fixture = createReleaseFixture(t);
|
||||
fs.writeFileSync(path.join(fixture.root, "package.json"), '{"version":"01.2.3"}\n');
|
||||
|
||||
const result = runTagRelease(fixture);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, "");
|
||||
assertStructuredError(JSON.parse(result.stderr));
|
||||
assert.deepEqual(readGitCalls(fixture), []);
|
||||
});
|
||||
});
|
||||
168
scripts/release-workflow.test.js
Normal file
168
scripts/release-workflow.test.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const releaseWorkflow = fs.readFileSync(
|
||||
path.join(repoRoot, ".github/workflows/release.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const previewWorkflow = fs.readFileSync(
|
||||
path.join(repoRoot, ".github/workflows/pkg-pr-new.yml"),
|
||||
"utf8",
|
||||
);
|
||||
function topLevelBlock(source, name) {
|
||||
const match = source.match(
|
||||
new RegExp(
|
||||
`^${name}:\\n([\\s\\S]*?)(?=^[A-Za-z][A-Za-z0-9_-]*:|(?![\\s\\S]))`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
assert.ok(match, `missing top-level ${name} block`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function jobBlock(source, name) {
|
||||
const jobs = topLevelBlock(source, "jobs");
|
||||
const match = jobs.match(
|
||||
new RegExp(
|
||||
`^ ${name}:\\n([\\s\\S]*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|(?![\\s\\S]))`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
assert.ok(match, `missing ${name} job`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function assertInOrder(source, snippets) {
|
||||
let previous = -1;
|
||||
for (const snippet of snippets) {
|
||||
const index = source.indexOf(snippet);
|
||||
assert.ok(index >= 0, `missing workflow fragment: ${snippet}`);
|
||||
assert.ok(index > previous, `workflow fragment is out of order: ${snippet}`);
|
||||
previous = index;
|
||||
}
|
||||
}
|
||||
|
||||
function permissionLines(job) {
|
||||
const match = job.match(/^ permissions:\n((?: .+\n)+)/m);
|
||||
assert.ok(match, "missing job permissions");
|
||||
return match[1].trim().split("\n").map((line) => line.trim()).sort();
|
||||
}
|
||||
|
||||
describe("release workflow contract", () => {
|
||||
it("has only the version-tag production trigger", () => {
|
||||
const trigger = topLevelBlock(releaseWorkflow, "on");
|
||||
|
||||
assert.match(trigger, /^on:\n push:\n tags:\n - 'v\*'\n+$/);
|
||||
for (const forbidden of [
|
||||
"workflow_dispatch:",
|
||||
"workflow_run:",
|
||||
"pull_request:",
|
||||
"pull_request_target:",
|
||||
]) {
|
||||
assert.equal(releaseWorkflow.includes(forbidden), false, forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs preflight before every release side effect", () => {
|
||||
const preflight = jobBlock(releaseWorkflow, "preflight");
|
||||
|
||||
assert.deepEqual(permissionLines(preflight), ["contents: read"]);
|
||||
assertInOrder(preflight, [
|
||||
"actions/checkout@",
|
||||
"fetch-depth: 0",
|
||||
"actions/setup-node@",
|
||||
"node-version: '22.14.0'",
|
||||
"node scripts/release-preflight.js --tag \"$TAG\"",
|
||||
"git rev-parse --verify 'HEAD^{commit}'",
|
||||
"git rev-parse --verify \"refs/tags/${TAG}^{commit}\"",
|
||||
'if [[ "$TAG" == *-beta.* ]]',
|
||||
'git fetch origin "$REHEARSAL_BRANCH"',
|
||||
"git rev-parse --verify 'FETCH_HEAD^{commit}'",
|
||||
"git fetch origin main",
|
||||
'git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"',
|
||||
]);
|
||||
assert.equal(preflight.includes("gh release"), false);
|
||||
assert.equal(preflight.includes("npm publish"), false);
|
||||
});
|
||||
|
||||
it("builds a verified staging asset before approval", () => {
|
||||
const build = jobBlock(releaseWorkflow, "build-stage-assets");
|
||||
|
||||
assert.match(build, /needs: preflight/);
|
||||
assert.deepEqual(permissionLines(build), ["contents: read"]);
|
||||
assert.doesNotMatch(build, /^ environment:/m);
|
||||
assert.match(build, /actions\/setup-go@[0-9a-f]{40}/);
|
||||
assert.match(build, /actions\/setup-python@[0-9a-f]{40}/);
|
||||
assert.match(
|
||||
build,
|
||||
/actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/,
|
||||
);
|
||||
assert.match(build, /node-version: '22.14.0'/);
|
||||
assert.match(build, /registry-url: 'https:\/\/registry\.npmjs\.org'/);
|
||||
assert.match(build, /package-manager-cache: false/);
|
||||
assert.match(build, /npm install --global npm@11\.16\.0/);
|
||||
assert.match(build, /goreleaser\/goreleaser-action@[0-9a-f]{40}/);
|
||||
assert.match(build, /args: release --clean --skip=publish/);
|
||||
assertInOrder(build, [
|
||||
"actions/setup-go@",
|
||||
"actions/setup-python@",
|
||||
"actions/setup-node@",
|
||||
"npm install --global npm@11.16.0",
|
||||
"goreleaser/goreleaser-action@",
|
||||
"sha256sum --check checksums.txt",
|
||||
"cp dist/checksums.txt checksums.txt",
|
||||
"npm pack --ignore-scripts --json",
|
||||
"tar -tzf \"$PACK_FILE\" | grep -qx 'package/checksums.txt'",
|
||||
"actions/upload-artifact@",
|
||||
]);
|
||||
assert.equal(build.includes("npm stage publish"), false);
|
||||
});
|
||||
|
||||
it("limits the protected job to verifying and staging the prepared npm asset", () => {
|
||||
const publish = jobBlock(releaseWorkflow, "stage-publish");
|
||||
|
||||
assert.match(publish, /needs: build-stage-assets/);
|
||||
assert.deepEqual(permissionLines(publish), ["id-token: write"]);
|
||||
assert.match(publish, /^ environment: npm-production$/m);
|
||||
assert.doesNotMatch(publish, /actions\/checkout@/);
|
||||
assert.doesNotMatch(publish, /goreleaser\/goreleaser-action@/);
|
||||
assert.doesNotMatch(publish, /GITHUB_TOKEN:/);
|
||||
assertInOrder(publish, [
|
||||
"actions/setup-node@",
|
||||
"npm install --global npm@11.16.0",
|
||||
"actions/download-artifact@",
|
||||
"sha256sum --check checksums.txt",
|
||||
"tar -tzf \"$PACK_FILE\" | grep -qx 'package/checksums.txt'",
|
||||
'npm stage publish "${{ steps.asset.outputs.filename }}" --access public --tag beta',
|
||||
]);
|
||||
for (const forbidden of [
|
||||
"gh release download",
|
||||
"npm view",
|
||||
"LOCAL_INTEGRITY",
|
||||
"REMOTE_INTEGRITY",
|
||||
"secrets.NPM_TOKEN",
|
||||
"NODE_AUTH_TOKEN",
|
||||
"GITHUB_TOKEN:",
|
||||
"gh release create",
|
||||
]) {
|
||||
assert.equal(releaseWorkflow.includes(forbidden), false, forbidden);
|
||||
}
|
||||
assert.equal(/(^|\s)npm publish(?:\s|$)/m.test(publish), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview isolation", () => {
|
||||
it("keeps preview publishing away from production credentials and registry", () => {
|
||||
assert.equal(previewWorkflow.includes("id-token: write"), false);
|
||||
assert.equal(previewWorkflow.includes("npm publish"), false);
|
||||
assert.equal(previewWorkflow.includes("registry.npmjs.org"), false);
|
||||
assert.equal(previewWorkflow.includes("secrets.NPM_TOKEN"), false);
|
||||
assert.equal(previewWorkflow.includes("NODE_AUTH_TOKEN"), false);
|
||||
});
|
||||
});
|
||||
@@ -3,49 +3,102 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Read version from package.json
|
||||
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
TAG="v${VERSION}"
|
||||
REHEARSAL_BRANCH="test/npm-staged-publish-rehearsal"
|
||||
PUSH_TAG=false
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: could not read version from package.json" >&2
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "--push" ]; then
|
||||
PUSH_TAG=true
|
||||
elif [ "$#" -ne 0 ]; then
|
||||
echo "Usage: $0 [--push]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${VERSION}"
|
||||
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
|
||||
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
|
||||
echo "Error: rehearsal releases require an X.Y.Z-beta.N version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Tag: ${TAG}"
|
||||
|
||||
# Check if tag already exists locally
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} already exists locally, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if tag already exists on remote
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag ${TAG} already exists on remote, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure package.json changes are committed before tagging
|
||||
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
|
||||
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ "${CURRENT_BRANCH}" != "${REHEARSAL_BRANCH}" ]; then
|
||||
echo "Error: rehearsal tags must be created from ${REHEARSAL_BRANCH}; current branch is '${CURRENT_BRANCH}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure current branch is pushed to remote before tagging
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
LOCAL_SHA=$(git rev-parse HEAD)
|
||||
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
|
||||
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
||||
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Error: the working tree must be clean before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create and push tag
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
git fetch origin "${REHEARSAL_BRANCH}"
|
||||
|
||||
echo "Successfully created and pushed tag ${TAG}"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
FETCHED_REHEARSAL_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
|
||||
if [ "${HEAD_SHA}" != "${FETCHED_REHEARSAL_SHA}" ]; then
|
||||
echo "Error: HEAD must exactly match origin/${REHEARSAL_BRANCH} before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKFLOW=$(git show "${HEAD_SHA}:.github/workflows/release.yml")
|
||||
if ! grep -Fq 'args: release --clean --skip=publish' <<<"${WORKFLOW}" ||
|
||||
! grep -Eq 'npm stage publish .*--tag beta' <<<"${WORKFLOW}" ||
|
||||
grep -Eq '(^|[[:space:]])npm publish([[:space:]]|$)' <<<"${WORKFLOW}" ||
|
||||
grep -Eq 'gh[[:space:]]+release([[:space:]]|$)' <<<"${WORKFLOW}" ||
|
||||
grep -Eq 'contents:[[:space:]]*write' <<<"${WORKFLOW}" ||
|
||||
grep -Fq 'GITHUB_TOKEN:' <<<"${WORKFLOW}"; then
|
||||
echo "Error: the tagged workflow must be stage-only, read-only for repository contents, and must not create a GitHub Release or publish npm live." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
NPM_VIEW_OUTPUT=$(npm view "@larksuite/cli@${VERSION}" version --registry=https://registry.npmjs.org/ 2>&1)
|
||||
NPM_VIEW_STATUS=$?
|
||||
set -e
|
||||
if [ "${NPM_VIEW_STATUS}" -eq 0 ]; then
|
||||
echo "Error: @larksuite/cli@${VERSION} already exists on npm." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq 'E404|404 Not Found' <<<"${NPM_VIEW_OUTPUT}"; then
|
||||
echo "Error: npm version lookup failed; refusing to assume the version is unused." >&2
|
||||
echo "${NPM_VIEW_OUTPUT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "Error: local tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
|
||||
if [ -n "${REMOTE_TAG}" ]; then
|
||||
echo "Error: remote tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${PUSH_TAG}" != true ]; then
|
||||
echo "Checks passed. No tag was created or pushed."
|
||||
echo "Run '$0 --push' only after reviewing the commit and workflow."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Branch: ${CURRENT_BRANCH}"
|
||||
echo "Commit: ${HEAD_SHA}"
|
||||
printf 'Type %s to create and push this tag: ' "${TAG}"
|
||||
read -r CONFIRM_TAG
|
||||
if [ "${CONFIRM_TAG}" != "${TAG}" ]; then
|
||||
echo "Error: confirmation did not exactly match ${TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${HEAD_SHA}"
|
||||
git push origin "refs/tags/${TAG}:refs/tags/${TAG}"
|
||||
|
||||
echo "Successfully pushed tag ${TAG}"
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -469,9 +469,6 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
@@ -577,8 +574,9 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -587,8 +585,9 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -597,7 +596,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
}
|
||||
d.
|
||||
POST("/open-apis/base/v3/bases").
|
||||
|
||||
@@ -14,11 +14,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionGrantGranted = "granted"
|
||||
PermissionGrantSkipped = "skipped"
|
||||
PermissionGrantFailed = "failed"
|
||||
permissionGrantPerm = "full_access"
|
||||
permissionGrantPermHint = "可管理权限"
|
||||
PermissionGrantGranted = "granted"
|
||||
PermissionGrantSkipped = "skipped"
|
||||
PermissionGrantFailed = "failed"
|
||||
permissionGrantPerm = "full_access"
|
||||
)
|
||||
|
||||
// AutoGrantCurrentUserDrivePermission grants full_access on a newly created
|
||||
@@ -121,7 +120,7 @@ func buildPermissionGrantResult(status, userOpenID, message, reason string) map[
|
||||
}
|
||||
|
||||
func permissionGrantPermMessage() string {
|
||||
return permissionGrantPerm + " (" + permissionGrantPermHint + ")"
|
||||
return permissionGrantPerm
|
||||
}
|
||||
|
||||
func permissionGrantPermType(resourceType string) string {
|
||||
|
||||
@@ -31,6 +31,14 @@ func apiErrWithScopes(code int, msg string, subjects ...string) error {
|
||||
return errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
}
|
||||
|
||||
func TestPermissionGrantPermMessageUsesAPINameOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := permissionGrantPermMessage(); got != "full_access" {
|
||||
t.Fatalf("permissionGrantPermMessage() = %q, want %q", got, "full_access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "perm-grant-test-skip",
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new document." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -173,11 +173,9 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
wantMessage := "Resource was created, but granting current user full_access failed: no permission. You can retry later or continue using bot identity."
|
||||
if grant["message"] != wantMessage {
|
||||
t.Fatalf("permission_grant.message = %q, want %q", grant["message"], wantMessage)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "auto-grant failed") {
|
||||
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
|
||||
|
||||
@@ -59,7 +59,7 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
}
|
||||
desc := "OpenAPI: create document"
|
||||
if runtime.IsBot() {
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new document."
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents").
|
||||
|
||||
@@ -73,10 +73,10 @@ func init() {
|
||||
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
|
||||
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("Task", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("Chat card", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("Base"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("Base"))
|
||||
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
|
||||
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
|
||||
|
||||
@@ -975,8 +975,8 @@ func TestConvertToIMMarkdownDocumentExpectedTagsAndEscaping(t *testing.T) {
|
||||
"````Go\nfmt.Println(\"hi\")\n```\n````",
|
||||
"`` `edge` `` $E=mc^2$ --- ![A \\[img\\]](https://example.com/i%281%29.png)",
|
||||
"``report`v1`.pdf``",
|
||||
"`任务``群聊卡片`",
|
||||
"`多维表格``多维表格``OKR`",
|
||||
"`Task``Chat card`",
|
||||
"`Base``Base``OKR`",
|
||||
}, "\n")
|
||||
|
||||
if got := convertToIMMarkdown(input, imCtx); got != want {
|
||||
|
||||
@@ -26,7 +26,7 @@ func v2FetchFlags() []common.Flag {
|
||||
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|error"},
|
||||
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"},
|
||||
|
||||
@@ -443,7 +443,7 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "bug|缺陷",
|
||||
"keyword": "bug|error",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
|
||||
const docsReferenceMapFlagDesc = "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
wantDesc := "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
|
||||
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
@@ -34,8 +36,8 @@ func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "@reference-map.json") {
|
||||
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
|
||||
if flag.Desc != wantDesc {
|
||||
t.Fatalf("reference-map help = %q, want English description %q", flag.Desc, wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -772,7 +772,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
|
||||
var inputs []commentReplyElementInput
|
||||
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"Example text\"}]'", err).WithParam("--content")
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content")
|
||||
|
||||
@@ -59,7 +59,7 @@ var DriveCreateFolder = common.Shortcut{
|
||||
Desc("[1] Create folder").
|
||||
Body(spec.RequestBody())
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
|
||||
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -90,6 +90,7 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Desc string `json:"desc"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
@@ -108,6 +109,10 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
|
||||
if got.API[0].Body["folder_token"] != "fld_parent" {
|
||||
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
|
||||
}
|
||||
wantDesc := "After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder."
|
||||
if got.API[0].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", got.API[0].Desc, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
|
||||
@@ -178,7 +183,7 @@ func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new folder." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.Dr
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
@@ -108,6 +108,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
@@ -117,6 +118,10 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
if len(got.API) != 4 {
|
||||
t.Fatalf("expected 4 API calls, got %d", len(got.API))
|
||||
}
|
||||
wantDesc := "After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it."
|
||||
if got.API[len(got.API)-1].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", got.API[len(got.API)-1].Desc, wantDesc)
|
||||
}
|
||||
|
||||
if got.API[0].Body != nil {
|
||||
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
|
||||
|
||||
@@ -1088,7 +1088,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
t.Fatalf("set --wiki-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
dry := DriveUpload.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
@@ -1100,7 +1100,8 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
PostUploadNote string `json:"post_upload_note"`
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
@@ -1123,6 +1124,10 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
if got.API[1].Body["with_url"] != true {
|
||||
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
|
||||
}
|
||||
wantPostUploadNote := "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file."
|
||||
if got.PostUploadNote != wantPostUploadNote {
|
||||
t.Fatalf("post_upload_note = %q, want %q", got.PostUploadNote, wantPostUploadNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new file." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new file." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ var DriveUpload = common.Shortcut{
|
||||
"Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
"Pass --file-token <file_token> to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.",
|
||||
"In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
|
||||
"In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime))
|
||||
@@ -137,7 +137,7 @@ var DriveUpload = common.Shortcut{
|
||||
"with_url": true,
|
||||
})
|
||||
if runtime.IsBot() && !isOverwrite {
|
||||
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
|
||||
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ package drive
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -71,3 +72,18 @@ func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) {
|
||||
t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadHelpTipUsesEnglishPermissionName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := "In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions."
|
||||
for _, tip := range DriveUpload.Tips {
|
||||
if strings.Contains(tip, "automatic full_access") {
|
||||
if tip != want {
|
||||
t.Fatalf("DriveUpload full_access tip = %q, want %q", tip, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("DriveUpload full_access help tip not found")
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestSheetCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new spreadsheet." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -156,10 +156,26 @@ func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) {
|
||||
"data": "",
|
||||
},
|
||||
nil, nil)
|
||||
rt = common.TestNewRuntimeContextWithIdentity(rt.Cmd, nil, core.AsBot)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"folder_token":"fldcn123"`) {
|
||||
t.Fatalf("DryRun should include folder_token, got: %s", got)
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(dryRun.API) != 1 {
|
||||
t.Fatalf("dry-run API count = %d, want 1", len(dryRun.API))
|
||||
}
|
||||
wantDesc := "After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet."
|
||||
if dryRun.API[0].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", dryRun.API[0].Desc, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreatePreservesBackendURL(t *testing.T) {
|
||||
|
||||
@@ -115,7 +115,7 @@ var SheetCreate = common.Shortcut{
|
||||
POST("/open-apis/sheets/v3/spreadsheets").
|
||||
Body(body)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.")
|
||||
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
|
||||
@@ -118,7 +118,7 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new presentation.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ var WikiNodeCreate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.")
|
||||
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new wiki node.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -635,7 +635,7 @@ func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new wiki node." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new wiki node." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.2
|
||||
version: 1.2.3
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive,认证/授权转 lark-shared。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -104,6 +104,8 @@ metadata:
|
||||
|
||||
## 写入前置规则
|
||||
|
||||
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
|
||||
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
|
||||
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。
|
||||
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
|
||||
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
|
||||
@@ -134,6 +136,8 @@ metadata:
|
||||
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API |
|
||||
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 |
|
||||
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue |
|
||||
| `Invalid discriminator value`(字段写入缺 `type`) | 按完整提交规则读取当前字段,只改目标内容后提交;不要只补 `type` 重试 |
|
||||
| filter 报 `value of type array` / `Only string values` | 用 record/view 的 tuple `--filter-json`(非 `+data-query` 对象型),value 按字段 type 选标量或数组;见 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md) |
|
||||
| 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 |
|
||||
| formula / lookup 创建失败 | 先读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md),再按 guide 重建请求 |
|
||||
| `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 |
|
||||
|
||||
@@ -174,11 +174,13 @@ lark-cli base +view-set-filter \
|
||||
|
||||
- 先读取当前筛选配置,理解现有 `logic` 和 `conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
|
||||
- 优先传字段 id,不要依赖字段名。
|
||||
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
|
||||
- 需要清空全部筛选时,直接传 `{"conditions":[]}`。
|
||||
|
||||
## 7. 易错点
|
||||
|
||||
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
|
||||
- 本 tuple DSL 由 `+view-set-filter` 与 `+record-list` / `+record-search` 的 `--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
|
||||
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
|
||||
- `user` / `group_chat` / `link` 不要写成单个标量。
|
||||
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
|
||||
Reference in New Issue
Block a user