fix(publish): clarify precheck failures and avoid duplicate toasts

This commit is contained in:
yun-zhi-ztl 2026-03-15 18:26:14 +08:00
parent de87446dd9
commit 30545d6a8a
6 changed files with 99 additions and 18 deletions

View file

@ -6,16 +6,23 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class BasicPrePublishValidator implements PrePublishValidator {
private static final List<Pattern> SECRET_PATTERNS = List.of(
Pattern.compile("AKIA[0-9A-Z]{16}"),
Pattern.compile("ghp_[A-Za-z0-9]{20,}"),
Pattern.compile("sk-[A-Za-z0-9]{20,}"),
Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?[A-Za-z0-9_\\-]{12,}")
private static final Pattern PLACEHOLDER_VALUE = Pattern.compile(
"(?i).*(your|example|sample|placeholder|changeme|replace|dummy|mock|test|fake|todo|xxx|redacted).*"
);
private static final List<SecretRule> SECRET_RULES = List.of(
new SecretRule(Pattern.compile("(AKIA[0-9A-Z]{16})"), 1, "cloud access key"),
new SecretRule(Pattern.compile("(ghp_[A-Za-z0-9]{20,})"), 1, "GitHub token"),
new SecretRule(Pattern.compile("(sk-[A-Za-z0-9]{20,})"), 1, "API key"),
new SecretRule(
Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?([A-Za-z0-9_\\-]{12,})"),
2,
"secret or token")
);
@Override
@ -27,9 +34,23 @@ public class BasicPrePublishValidator implements PrePublishValidator {
continue;
}
String content = new String(entry.content(), StandardCharsets.UTF_8);
for (Pattern secretPattern : SECRET_PATTERNS) {
if (secretPattern.matcher(content).find()) {
errors.add("Potential secret detected in " + entry.path());
String[] lines = content.split("\\R", -1);
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
for (SecretRule rule : SECRET_RULES) {
Matcher matcher = rule.pattern().matcher(line);
if (!matcher.find()) {
continue;
}
String matchedValue = matcher.group(rule.valueGroup());
if (isPlaceholderValue(matchedValue)) {
continue;
}
errors.add(entry.path()
+ " line " + (i + 1)
+ " contains a value that looks like a "
+ rule.label()
+ ". Replace real credentials with placeholders before publishing.");
break;
}
}
@ -51,4 +72,14 @@ public class BasicPrePublishValidator implements PrePublishValidator {
|| lowerPath.endsWith(".sh")
|| lowerPath.endsWith(".svg");
}
private boolean isPlaceholderValue(String value) {
if (value == null || value.isBlank()) {
return false;
}
return PLACEHOLDER_VALUE.matcher(value).matches()
|| value.chars().allMatch(ch -> ch == 'x' || ch == 'X' || ch == '*' || ch == '-');
}
private record SecretRule(Pattern pattern, int valueGroup, String label) {}
}

View file

@ -15,7 +15,7 @@ class BasicPrePublishValidatorTest {
private final BasicPrePublishValidator validator = new BasicPrePublishValidator();
@Test
void shouldRejectObviousCredentialLeak() {
void shouldRejectObviousCredentialLeakWithHelpfulLocation() {
PackageEntry skillMd = new PackageEntry(
"SKILL.md",
"""
@ -23,26 +23,24 @@ class BasicPrePublishValidatorTest {
name: Secret Skill
version: 1.0.0
---
token=sk-abcdefghijklmnopqrstuvwxyz123456
""".getBytes(StandardCharsets.UTF_8),
47,
91,
"text/markdown"
);
PackageEntry config = new PackageEntry(
"config.txt",
"OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456".getBytes(StandardCharsets.UTF_8),
50,
"text/plain"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(skillMd, config),
List.of(skillMd),
new SkillMetadata("Secret Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(error -> error.contains("Potential secret detected")));
assertTrue(result.errors().stream().anyMatch(error ->
error.contains("SKILL.md")
&& error.contains("line 5")
&& error.contains("looks like a")));
}
@Test
@ -74,4 +72,30 @@ class BasicPrePublishValidatorTest {
assertTrue(result.passed());
}
@Test
void shouldIgnoreObviousPlaceholderSecrets() {
PackageEntry skillMd = new PackageEntry(
"SKILL.md",
"""
---
name: Example Skill
version: 1.0.0
---
token=YOUR_TOKEN_HERE
api_key=example-key-value
""".getBytes(StandardCharsets.UTF_8),
102,
"text/markdown"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(skillMd),
new SkillMetadata("Example Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertTrue(result.passed());
}
}

View file

@ -589,6 +589,8 @@
"timeoutDescription": "The publish request took too long. Please check the skill list later or try again.",
"versionExistsTitle": "Version already exists",
"versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.",
"precheckFailedTitle": "Pre-publish check failed",
"precheckFailedDescription": "The package appears to contain a secret, token, or password. Replace real credentials with placeholders and try again.",
"selectRequired": "Please select namespace and file"
},
"toast": {

View file

@ -589,6 +589,8 @@
"timeoutDescription": "本次发布等待时间过长,请稍后到技能列表确认结果,或重新尝试发布。",
"versionExistsTitle": "版本号已存在",
"versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。",
"precheckFailedTitle": "发布前校验未通过",
"precheckFailedDescription": "技能包中包含疑似密钥、令牌或密码内容。请将真实凭证替换为占位符后再重试。",
"selectRequired": "请选择命名空间和文件"
},
"toast": {

View file

@ -21,6 +21,17 @@ function isVersionExistsMessage(message?: string): boolean {
|| message.includes('版本已存在')
}
function isPrecheckFailureMessage(message?: string): boolean {
if (!message) {
return false
}
return message.includes('error.skill.publish.precheck.failed')
|| message.includes('Pre-publish validation failed')
|| message.includes('预发布校验失败')
|| message.includes('looks like a secret or token')
}
export function PublishPage() {
const { t } = useTranslation()
const navigate = useNavigate()
@ -68,6 +79,14 @@ export function PublishPage() {
return
}
if (error instanceof ApiError && isPrecheckFailureMessage(error.serverMessage || error.message)) {
toast.error(
t('publish.precheckFailedTitle'),
error.serverMessage || t('publish.precheckFailedDescription'),
)
return
}
toast.error(t('publish.error'), error instanceof Error ? error.message : '')
}
}

View file

@ -165,6 +165,9 @@ export function usePublishSkill() {
return useMutation({
mutationFn: publishSkill,
meta: {
skipGlobalErrorHandler: true,
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
},