mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
* feat(apps): replace +html-publish cwd hard-reject with credential-file scan The previous --path == "." block was a coarse heuristic: it caught the common foot-gun of publishing a repo root, but also rejected legitimate clean cwds, and let a ./dist with a forgotten .env ship the secret through anyway (the sensitive-paths scanner was advisory and never ran on the Execute path). Move the gate from path shape to path content: - Validate now walks --path candidates and rejects publishes that include well-known credential files (.env / .env.* / .npmrc / .netrc / .git-credentials / .aws/credentials / .gcloud/credentials* / .docker/config.json / .kube/config). Living in Validate (not DryRun) means dry-run returns non-zero on hit too, so the dry-run preview matches Execute. - Narrow the credential pattern set. .git/, SSH private keys, *.pem and *.key are out of scope -- they're not env-token files and the false-positive rate (public certs, docs about key formats) is high. - Add --allow-sensitive as the escape hatch for legitimate cases (e.g. a docs site shipping .env.example on purpose). DryRun surfaces the waived list in sensitive_waived so the caller can relay it. - Drop the cwd defense-in-depth in runHTMLPublish. A clean cwd is now a valid publish target. The lark-apps skill and the html-publish reference are updated to describe the new gate, the override flag, and the patterns now explicitly out of scope. * feat(apps): drop .gcloud/* from credential-file scan The .gcloud/credentials pattern matched a non-existent path: gcloud's actual config dir is ~/.config/gcloud/ (XDG-based), and the real credential files there are credentials.db / access_tokens.db / application_default_credentials.json -- none of which would land under a .gcloud/ segment in a publish payload. Drop the rule rather than fix it: the realistic gcloud foot-gun would require recognizing the .config/gcloud/* tree by file basename, which is a broader change than the targeted env/cred scan in this PR. The remaining 7 patterns (.env / .env.* / .npmrc / .netrc / .git-credentials / .aws/credentials / .docker/config.json / .kube/config) cover the common Node/Python/CLI-tooling foot-guns. * fix(apps): close credential-scan bypass when --path is the parent dir itself isSensitiveRelPath anchors cloud-SDK matchers on adjacent parent/file segments (.aws/credentials, .docker/config.json, .kube/config), but walker strips that parent via filepath.Rel when --path is the conventional parent dir (e.g. ./.aws), yielding a bare RelPath="credentials" that slipped through silently. Same bypass for the single-file form --path ./.aws/credentials (walker sets RelPath = Base(rootPath)). Wrap the scan in isSensitiveCandidate: keep the fast RelPath scan, and on miss fall back to filepath.Abs(AbsPath) so the parent segment is visible again. isSensitiveRelPath itself is unchanged; existing tests still pin its pure-function contract. * fix(apps): drop filepath.Abs from sensitive scan to satisfy forbidigo lint The previous fix called filepath.Abs(c.AbsPath) — banned by the repo's forbidigo rule because shortcuts must not reach into the filesystem for path resolution. Reframe the same fix without fs access: re-prepend the root's basename (or, for the single-file form, the parent dir's basename of rootPath) to RelPath and re-scan only the parent-anchored credential pairs (.aws/credentials, .docker/config.json, .kube/config). Leaf matchers (.env / .npmrc / ...) stay scoped to RelPath — incidentally closing a latent false-positive where --path /home/alice/.env/dist would have flagged every file under it just because .env appeared in the absolute path.
221 lines
8.2 KiB
Go
221 lines
8.2 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
|
||
"github.com/larksuite/cli/extension/fileio"
|
||
"github.com/larksuite/cli/internal/output"
|
||
"github.com/larksuite/cli/internal/validate"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
|
||
var AppsHTMLPublish = common.Shortcut{
|
||
Service: appsService,
|
||
Command: "+html-publish",
|
||
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
|
||
Risk: "write",
|
||
Scopes: []string{"spark:app:publish"},
|
||
AuthTypes: []string{"user"},
|
||
HasFormat: true,
|
||
Flags: []common.Flag{
|
||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||
{Name: "path", Desc: "path to HTML file or directory", Required: true},
|
||
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
|
||
},
|
||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||
return output.ErrValidation("--app-id is required")
|
||
}
|
||
path := strings.TrimSpace(rctx.Str("path"))
|
||
if path == "" {
|
||
return output.ErrValidation("--path is required")
|
||
}
|
||
// Block well-known credential files in the publish payload unless the
|
||
// caller explicitly opts in. Lives in Validate (not DryRun) so that
|
||
// `--dry-run` returns non-zero on hit — the framework runs Validate
|
||
// before branching to DryRun/Execute, so both paths share this gate.
|
||
if rctx.Bool("allow-sensitive") {
|
||
return nil
|
||
}
|
||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
|
||
if err != nil {
|
||
// Don't fail Validate on walk errors (bad --path, etc.) — let
|
||
// DryRun/Execute surface them in their own (richer) envelopes.
|
||
return nil
|
||
}
|
||
var hits []string
|
||
for _, c := range candidates {
|
||
if isSensitiveCandidate(path, c) {
|
||
hits = append(hits, c.RelPath)
|
||
}
|
||
}
|
||
if len(hits) > 0 {
|
||
return sensitiveCandidatesError(hits)
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||
path := strings.TrimSpace(rctx.Str("path"))
|
||
dry := common.NewDryRunAPI()
|
||
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
|
||
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
|
||
Set("content_type", "multipart/form-data")
|
||
|
||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
|
||
if err != nil {
|
||
dry.Set("path_error", err.Error())
|
||
return dry
|
||
}
|
||
if err := ensureIndexHTML(candidates); err != nil {
|
||
// Surface the same failure Execute would hit, but as a structured
|
||
// envelope field so dry-run still exits 0 (matches repo convention
|
||
// for dry-run "advisory preview" semantics).
|
||
dry.Set("validation_error", err.Error())
|
||
}
|
||
dry.Set("file_count", len(candidates))
|
||
var totalSize int64
|
||
names := make([]string, 0, len(candidates))
|
||
for _, c := range candidates {
|
||
totalSize += c.Size
|
||
names = append(names, c.RelPath)
|
||
}
|
||
dry.Set("total_size_bytes", totalSize)
|
||
dry.Set("files", names)
|
||
// Sensitive-file rejection lives in Validate (so dry-run exits non-zero
|
||
// on hit). When --allow-sensitive is set, still surface the list here
|
||
// as an info field so the caller sees what was waived.
|
||
if rctx.Bool("allow-sensitive") {
|
||
var waived []string
|
||
for _, c := range candidates {
|
||
if isSensitiveCandidate(path, c) {
|
||
waived = append(waived, c.RelPath)
|
||
}
|
||
}
|
||
if len(waived) > 0 {
|
||
dry.Set("sensitive_waived", waived)
|
||
dry.Set("sensitive_waived_summary", fmt.Sprintf("%d credential file(s) included because --allow-sensitive is set", len(waived)))
|
||
}
|
||
}
|
||
return dry
|
||
},
|
||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||
spec := appsHTMLPublishSpec{
|
||
AppID: strings.TrimSpace(rctx.Str("app-id")),
|
||
Path: strings.TrimSpace(rctx.Str("path")),
|
||
}
|
||
client := appsHTMLPublishAPI{runtime: rctx}
|
||
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||
if url, ok := out["url"].(string); ok && url != "" {
|
||
fmt.Fprintf(w, "url: %s\n", url)
|
||
}
|
||
})
|
||
return nil
|
||
},
|
||
}
|
||
|
||
type appsHTMLPublishSpec struct {
|
||
AppID string
|
||
Path string
|
||
}
|
||
|
||
// maxSensitiveListInError caps how many credential-file matches we list inline
|
||
// in the validation error, so the message stays readable when a misconfigured
|
||
// payload has many hits (e.g. a directory tree accidentally containing
|
||
// per-environment .env.* files for every stage).
|
||
const maxSensitiveListInError = 5
|
||
|
||
// sensitiveCandidatesError builds the Validate-time rejection when --path
|
||
// contains credential files and --allow-sensitive was not set.
|
||
func sensitiveCandidatesError(hits []string) error {
|
||
var sample string
|
||
if len(hits) <= maxSensitiveListInError {
|
||
sample = strings.Join(hits, ", ")
|
||
} else {
|
||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||
}
|
||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||
fmt.Sprintf("--path contains %d credential file(s) that should not be published: %s", len(hits), sample),
|
||
"remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||
}
|
||
|
||
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
|
||
// 用 var 而非 const,便于单测调小覆盖拦截路径。
|
||
var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
|
||
|
||
// maxHTMLPublishRawBytes caps the total UNCOMPRESSED candidate size before
|
||
// tar+gzip writes them into the in-memory buffer. Defends against
|
||
// highly-compressible "decompression bomb" inputs (e.g. 50GB of zeros)
|
||
// that would balloon process memory before the gzip-after check fires.
|
||
// 200MB is much higher than any plausible legitimate HTML/static-site
|
||
// payload but low enough to stay well under typical container memory.
|
||
// Mutable for tests.
|
||
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
|
||
|
||
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
|
||
// 目录形态:根目录下必须有 index.html。
|
||
// 单文件形态:文件名必须就是 index.html。
|
||
// 妙搭服务端用 index.html 作为应用入口。
|
||
func ensureIndexHTML(candidates []htmlPublishCandidate) error {
|
||
for _, c := range candidates {
|
||
if c.RelPath == "index.html" {
|
||
return nil
|
||
}
|
||
}
|
||
return output.ErrWithHint(output.ExitAPI, "validation",
|
||
"--path 中缺少 index.html",
|
||
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html,单文件形态把文件命名为 index.html")
|
||
}
|
||
|
||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
|
||
if err != nil {
|
||
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
|
||
}
|
||
if err := ensureIndexHTML(candidates); err != nil {
|
||
return nil, err
|
||
}
|
||
var rawTotal int64
|
||
for _, c := range candidates {
|
||
rawTotal += c.Size
|
||
}
|
||
if rawTotal > maxHTMLPublishRawBytes {
|
||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes),
|
||
"在 tar+gzip 进入内存前拦截,避免 OOM;精简 --path 内容或选择更小的子目录")
|
||
}
|
||
tarball, err := buildHTMLPublishTarball(fio, candidates)
|
||
if err != nil {
|
||
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
|
||
}
|
||
|
||
if tarball.Size > maxHTMLPublishTarballBytes {
|
||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
|
||
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
|
||
}
|
||
|
||
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
out := map[string]interface{}{}
|
||
if resp.URL != "" {
|
||
out["url"] = resp.URL
|
||
}
|
||
return out, nil
|
||
}
|