mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-10 03:27:54 +00:00
fix(publish): clarify precheck failures and avoid duplicate toasts
This commit is contained in:
parent
de87446dd9
commit
30545d6a8a
6 changed files with 99 additions and 18 deletions
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -589,6 +589,8 @@
|
|||
"timeoutDescription": "本次发布等待时间过长,请稍后到技能列表确认结果,或重新尝试发布。",
|
||||
"versionExistsTitle": "版本号已存在",
|
||||
"versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。",
|
||||
"precheckFailedTitle": "发布前校验未通过",
|
||||
"precheckFailedDescription": "技能包中包含疑似密钥、令牌或密码内容。请将真实凭证替换为占位符后再重试。",
|
||||
"selectRequired": "请选择命名空间和文件"
|
||||
},
|
||||
"toast": {
|
||||
|
|
|
|||
|
|
@ -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 : '')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ export function usePublishSkill() {
|
|||
|
||||
return useMutation({
|
||||
mutationFn: publishSkill,
|
||||
meta: {
|
||||
skipGlobalErrorHandler: true,
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue