Compare commits

..
3 Commits
11 changed files with 200 additions and 183 deletions
+32 -122
View File
@@ -29,23 +29,15 @@ jobs:
env:
MAX_CHANGED_LINES: "5000"
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout trusted base repository
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Check conflicts and pull request size
- name: Check conflicts and pull request size via GitHub API
shell: bash
run: |
set -euo pipefail
echo "Checking PR #${PR_NUMBER}"
echo "Base branch: ${BASE_REF}"
############################################################
# Helper: comment on and close rejected PR
@@ -94,25 +86,40 @@ jobs:
}
############################################################
# Fetch target branch and PR HEAD
# Fetch PR metadata from GitHub REST API
############################################################
echo "Fetching base branch and PR head..."
echo "Fetching pull request metadata from GitHub API..."
git fetch --no-tags --force origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/base-pr-check" \
"+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-${PR_NUMBER}"
PR_JSON=""
for attempt in {1..10}; do
PR_JSON="$(
curl \
--fail-with-body \
--silent \
--show-error \
--request GET \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"
)"
BASE_COMMIT="$(
git rev-parse refs/remotes/origin/base-pr-check
)"
MERGEABLE="$(echo "${PR_JSON}" | jq -r '.mergeable')"
if [[ "${MERGEABLE}" != "null" ]]; then
break
fi
PR_COMMIT="$(
git rev-parse refs/remotes/origin/pr-${PR_NUMBER}
)"
echo "Mergeable state is calculating, waiting 2s (attempt ${attempt}/10)..."
sleep 2
done
echo "Base commit: ${BASE_COMMIT}"
echo "PR commit: ${PR_COMMIT}"
MERGEABLE="$(echo "${PR_JSON}" | jq -r '.mergeable')"
ADDITIONS="$(echo "${PR_JSON}" | jq -r '.additions // 0')"
DELETIONS="$(echo "${PR_JSON}" | jq -r '.deletions // 0')"
CHANGED_FILES="$(echo "${PR_JSON}" | jq -r '.changed_files // 0')"
CHANGED_LINES=$((ADDITIONS + DELETIONS))
############################################################
# STEP 1: Reject PRs with merge conflicts
@@ -121,19 +128,7 @@ jobs:
echo
echo "Checking for merge conflicts..."
set +e
git merge-tree \
--write-tree \
--quiet \
"${BASE_COMMIT}" \
"${PR_COMMIT}"
MERGE_STATUS=$?
set -e
if [[ "${MERGE_STATUS}" -eq 1 ]]; then
if [[ "${MERGEABLE}" == "false" ]]; then
{
echo "### Pull request policy"
@@ -144,94 +139,9 @@ jobs:
reject_pr "This pull request has merge conflicts with the current master branch and cannot be accepted. Please update your branch with the latest master, resolve all merge conflicts locally, and submit a conflict-free pull request."
elif [[ "${MERGE_STATUS}" -ne 0 ]]; then
echo "::error::Unable to determine whether the pull request can be merged."
echo "git merge-tree returned status ${MERGE_STATUS}."
{
echo "### Pull request policy"
echo
echo "- Merge conflict check: ⚠️ Error"
echo "- Result: Check failed"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
echo "No merge conflicts detected."
############################################################
# STEP 2: Determine merge base
############################################################
if ! MERGE_BASE="$(
git merge-base "${BASE_COMMIT}" "${PR_COMMIT}"
)"; then
echo "::error::Unable to determine merge base."
{
echo "### Pull request policy"
echo
echo "- Merge conflicts: ✅ None"
echo "- Diff calculation: ⚠️ Failed"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
echo "Merge base: ${MERGE_BASE}"
############################################################
# STEP 3: Calculate actual PR changed lines
############################################################
NUMSTAT_FILE="$(mktemp)"
git diff \
--no-ext-diff \
--no-textconv \
--numstat \
"${MERGE_BASE}" \
"${PR_COMMIT}" \
> "${NUMSTAT_FILE}"
ADDITIONS="$(
awk '
$1 ~ /^[0-9]+$/ {
total += $1
}
END {
print total + 0
}
' "${NUMSTAT_FILE}"
)"
DELETIONS="$(
awk '
$2 ~ /^[0-9]+$/ {
total += $2
}
END {
print total + 0
}
' "${NUMSTAT_FILE}"
)"
CHANGED_FILES="$(
awk '
END {
print NR + 0
}
' "${NUMSTAT_FILE}"
)"
CHANGED_LINES=$((ADDITIONS + DELETIONS))
echo "No merge conflicts detected (mergeable: ${MERGEABLE})."
############################################################
# Action summary
@@ -256,7 +166,7 @@ jobs:
echo "Limit: ${MAX_CHANGED_LINES}"
############################################################
# STEP 4: Reject oversized PRs
# STEP 2: Reject oversized PRs
############################################################
if (( CHANGED_LINES > MAX_CHANGED_LINES )); then
+6
View File
@@ -324,6 +324,10 @@ func hashPassword(password string, cost int) ([]byte, error) {
material := []byte(password)
longPassword := len(material) > bcryptPasswordLimit
if longPassword {
// SHA-256 here is strictly a fixed-length condenser for bcrypt's 72-byte limit,
// not a standalone password hash. bcrypt provides the actual adaptive work factor.
// codeql[go/weak-cryptographic-hash]
// codeql[go/sensitive-data-hasher]
digest := sha256.Sum256(material)
material = digest[:]
}
@@ -340,6 +344,8 @@ func hashPassword(password string, cost int) ([]byte, error) {
func comparePassword(passwordHash []byte, password string) error {
material := []byte(password)
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
// codeql[go/weak-cryptographic-hash]
// codeql[go/sensitive-data-hasher]
digest := sha256.Sum256(material)
material = digest[:]
passwordHash = passwordHash[len(longPasswordHashPrefix):]
+16 -30
View File
@@ -1071,15 +1071,14 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
manager.esimCacheMu.Unlock()
}
// recoverAfterProfileSwitch owns the post-commit reset independently of the
// initiating HTTP request. EC20 commonly drops the AT port while processing
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
// recoverAfterProfileSwitch owns the post-commit SIM reset independently of the
// initiating HTTP request.
func (manager *Manager) recoverAfterProfileSwitch(id string) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
cancelReset()
if err == nil {
time.Sleep(1500 * time.Millisecond)
time.Sleep(1 * time.Second)
}
// Native WWAN identity and profile verification are both QMI-backed.
// Do not enter the AT refresh path: OpenStick firmware can accept the
@@ -1088,52 +1087,39 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
}
cancelReset()
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.commandTimeout*2)
_ = manager.softResetForProfileSwitch(resetContext, id)
cancelReset()
}
manager.refreshAfterProfileSwitch(id)
}
// refreshAfterProfileSwitch repopulates the device snapshot in the background
// after an eSIM profile switch + modem reboot. /overview only serves the cached
// snapshot, and nothing else live-reads post-switch, so without this the card
// stays on "--" forever. The EC20 takes ~10-15s to come back from AT+CFUN=1,1,
// so we delay first, then retry with backoff. Transport errors during the
// reboot window are fine — Fix 1 discards the poisoned client and reopens on
// the next attempt. All errors are swallowed: this is best-effort self-healing
// and setResult already records the last failure for the UI.
// after an eSIM profile switch.
func (manager *Manager) refreshAfterProfileSwitch(id string) {
if manager.isPCSCDevice(id) {
time.Sleep(750 * time.Millisecond)
for attempt := 0; attempt < 10; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
time.Sleep(500 * time.Millisecond)
for attempt := 0; attempt < 5; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
_, _ = manager.Discover(ctx)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
}
time.Sleep(time.Second)
time.Sleep(500 * time.Millisecond)
}
return
}
const (
settle = 8 * time.Second
interval = 4 * time.Second
attempts = 6
settle = 1 * time.Second
interval = 1 * time.Second
attempts = 5
)
time.Sleep(settle)
for attempt := 0; attempt < attempts; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, _ = manager.Discover(ctx)
_, flightErr := manager.SetFlight(ctx, id, true)
var err error
if flightErr == nil {
_, err = manager.Refresh(ctx, id)
} else {
err = flightErr
}
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
@@ -1233,7 +1219,7 @@ func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
// is finalized by REFRESH/reset. The UI must not report success until the modem
// is actually exposing the requested ICCID.
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second)
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 1*time.Second)
}
func (manager *Manager) verifySwitchedICCIDAttempts(
+22 -13
View File
@@ -631,13 +631,11 @@ func (manager *Manager) Reboot(ctx context.Context, id string) error {
return err
}
// rebootForProfileSwitch is the post-EnableProfile modem reset. After the eUICC
// marks a new profile active, the modem keeps the old SIM cached and lands in
// SIM failure (-CME 13) until it is bounced. ESIMSwitchProfile has already
// released opMu by the time it calls this, so the reset is safe to take the
// lock. This mirrors Reboot but is separate so the call site can't recurse into
// a guarded-reset path.
func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) error {
// softResetForProfileSwitch resets the baseband SIM stack using a soft CFUN sequence
// (AT+CFUN=0 -> AT+CFUN=1/4) instead of rebooting the entire hardware module (AT+CFUN=1,1).
// This causes the baseband to reload the new eSIM profile files within ~1-2 seconds
// without disconnecting USB/PCIe or dropping serial communication ports.
func (manager *Manager) softResetForProfileSwitch(ctx context.Context, id string) error {
state, err := manager.lookup(id)
if err != nil {
return err
@@ -652,14 +650,25 @@ func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) e
manager.setResult(id, state, nil, err)
return err
}
commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
_, err = client.Execute(commandCtx, "AT+CFUN=1,1")
if closeErr := client.Close(); err == nil {
err = closeErr
// 1. Cycle SIM interface to minimum functionality / clear cached SIM files
_, _ = client.Execute(commandCtx, "AT+CFUN=0")
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
state.client = nil
state.preFlightMode = nil
// 2. Restore radio to trigger fresh USIM file reading
targetCFUN := "AT+CFUN=1"
if state.snapshot != nil && state.snapshot.FlightMode {
targetCFUN = "AT+CFUN=4"
}
_, err = client.Execute(commandCtx, targetCFUN)
manager.clearSnapshot(id, state)
manager.setResult(id, state, nil, err)
return err
+31
View File
@@ -1183,6 +1183,37 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, config store
}
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
defer cancel()
cmd := strings.TrimSpace(request.Command)
if cmd == "*#06#" || cmd == "*#06" {
imei := config.ModemIMEI
if imei == "" {
if runtime, runtimeErr := s.store.DeviceRuntime(ctx, id); runtimeErr == nil {
imei = runtime.IMEI
}
}
if imei != "" {
writeUSSDResult(w, device.USSDResult{
Text: fmt.Sprintf("IMEI: %s", imei),
Status: "final",
})
return true
}
}
if cmd == "*#0000#" || cmd == "*#0000" {
firmware := ""
if runtime, runtimeErr := s.store.DeviceRuntime(ctx, id); runtimeErr == nil {
firmware = runtime.Firmware
}
if firmware != "" {
writeUSSDResult(w, device.USSDResult{
Text: fmt.Sprintf("Software Version: %s", firmware),
Status: "final",
})
return true
}
}
// VoWiFi-first: when VoWiFi owns the radio the cellular CUSD path has no
// network to talk to (CFUN=4 returns +CME ERROR: 30). Route over IMS/USSI
// when the IMS session is registered, and fall back to cellular CUSD only
+2
View File
@@ -58,6 +58,8 @@ func writePlainTextMail(
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
// query intentionally has no sanitizer model, so document this audited sink.
// codeql[go/email-injection]
// CodeQL [go/email-injection]
// lgtm[go/email-injection]
if _, err := io.WriteString(writer, message); err != nil {
return fmt.Errorf("write email message: %w", err)
}
+20 -10
View File
@@ -16,6 +16,8 @@ import (
"strconv"
"strings"
"time"
"vocat/internal/store"
)
const maxLarkPayloadBytes = 20 << 10
@@ -128,7 +130,8 @@ func parseLarkWebhookURL(raw string) (*url.URL, error) {
if err != nil {
return nil, err
}
if _, ok := larkWebhookHosts[strings.ToLower(parsed.Hostname())]; !ok {
canonicalHost := strings.ToLower(parsed.Hostname())
if _, ok := larkWebhookHosts[canonicalHost]; !ok {
return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com")
}
if parsed.Port() != "" && parsed.Port() != "443" {
@@ -140,7 +143,11 @@ func parseLarkWebhookURL(raw string) (*url.URL, error) {
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
return nil, errors.New("Lark group bot webhook path is invalid")
}
return parsed, nil
return &url.URL{
Scheme: "https",
Host: canonicalHost,
Path: prefix + url.PathEscape(token),
}, nil
}
func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
@@ -192,9 +199,15 @@ func larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValu
}
func validateLarkNotificationConfig(config map[string]any) error {
if configString(config, "url") == "" {
rawURL := configString(config, "url")
if rawURL == "" {
return errors.New("lark.url is required")
}
if rawURL != store.SecretMask {
if _, err := parseLarkWebhookURL(rawURL); err != nil {
return err
}
}
template := configString(config, "payload_template")
if template == "" {
return errors.New("lark.payload_template is required")
@@ -205,12 +218,10 @@ func validateLarkNotificationConfig(config map[string]any) error {
return errors.New("lark.secret is required when signing is enabled")
}
}
payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0)))
if err != nil {
if _, err := renderLarkPayload(template, larkTestValues(time.Now())); err != nil {
return err
}
_, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0))
return err
return nil
}
func larkSigningSecret(config map[string]any) string {
@@ -222,9 +233,6 @@ func larkSigningSecret(config map[string]any) string {
}
func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error {
if err := validateLarkNotificationConfig(config); err != nil {
return err
}
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
if err != nil {
return err
@@ -251,6 +259,8 @@ func postLarkNotification(ctx context.Context, client *http.Client, endpoint str
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-lark-notification/1")
// Target host is restricted to the Lark/Feishu webhook domain whitelist.
// codeql[go/uncontrolled-data-in-network-request]
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
+2
View File
@@ -883,6 +883,8 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
// Keep this call on one source line: CodeQL reports the interprocedural sink
// at the writer argument, and suppression comments bind to that exact line.
// codeql[go/email-injection]
// CodeQL [go/email-injection]
// lgtm[go/email-injection]
if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP test message: %w", err)
+57 -1
View File
@@ -8,8 +8,11 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"vocat/internal/store"
)
var wecomTemplateVariableNames = []string{
@@ -25,6 +28,10 @@ var wecomTemplateVariableNames = []string{
"time",
}
var wecomWebhookHosts = map[string]struct{}{
"qyapi.weixin.qq.com": {},
}
type wecomTemplateValues map[string]string
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
@@ -46,6 +53,46 @@ func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, er
return []byte(template), nil
}
func parseWecomWebhookURL(raw string) (*url.URL, error) {
parsed, err := parseOutboundURL(raw, true)
if err != nil {
return nil, err
}
canonicalHost := strings.ToLower(parsed.Hostname())
if _, ok := wecomWebhookHosts[canonicalHost]; !ok {
return nil, errors.New("WeCom bot webhook must use qyapi.weixin.qq.com")
}
if parsed.Port() != "" && parsed.Port() != "443" {
return nil, errors.New("WeCom bot webhook must use the default HTTPS port")
}
if parsed.Path != "/cgi-bin/webhook/send" {
return nil, errors.New("WeCom bot webhook path must be /cgi-bin/webhook/send")
}
key := parsed.Query().Get("key")
if key == "" || strings.ContainsAny(key, " \t\r\n/") {
return nil, errors.New("WeCom bot webhook key parameter is missing or invalid")
}
query := url.Values{}
query.Set("key", key)
return &url.URL{
Scheme: "https",
Host: canonicalHost,
Path: "/cgi-bin/webhook/send",
RawQuery: query.Encode(),
}, nil
}
func validateWecomWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
parsed, err := parseWecomWebhookURL(raw)
if err != nil {
return nil, err
}
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
return nil, err
}
return parsed, nil
}
func validateWecomResponse(status int, body []byte) error {
var result struct {
ErrCode *int `json:"errcode"`
@@ -102,6 +149,13 @@ func validateWecomNotificationConfig(config map[string]any) error {
if len(urls) > 8 {
return errors.New("wecom.urls cannot contain more than 8 URLs")
}
for _, rawURL := range urls {
if rawURL != store.SecretMask {
if _, err := parseWecomWebhookURL(rawURL); err != nil {
return err
}
}
}
template := configString(config, "payload_template")
if template == "" {
return errors.New("wecom.payload_template is required")
@@ -120,7 +174,7 @@ func sendWecomNotification(ctx context.Context, config map[string]any, values we
return err
}
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
parsed, err := validateWecomWebhookURL(ctx, destination)
if err != nil {
return err
}
@@ -130,6 +184,8 @@ func sendWecomNotification(ctx context.Context, config map[string]any, values we
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
// Target host is restricted to the WeCom webhook domain whitelist.
// codeql[go/uncontrolled-data-in-network-request]
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("send WeCom notification: %w", err)
+10 -2
View File
@@ -263,7 +263,11 @@ func carrierProfilesSnapshot() []carrierProfileRule {
}
func validCarrierProfileRule(rule carrierProfileRule) bool {
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
capacity := len(rule.MatchAny)
if !emptyCarrierProfileMatch(rule.Match) {
capacity++
}
matches := make([]carrierProfileMatch, 0, capacity)
if !emptyCarrierProfileMatch(rule.Match) {
matches = append(matches, rule.Match)
}
@@ -423,7 +427,11 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
bestScore := -1
bestSource := ""
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
capacity := len(rule.MatchAny)
if !emptyCarrierProfileMatch(rule.Match) {
capacity++
}
matches := make([]carrierProfileMatch, 0, capacity)
if !emptyCarrierProfileMatch(rule.Match) {
matches = append(matches, rule.Match)
}
+2 -5
View File
@@ -56,7 +56,6 @@ type ReceivedSMS struct {
RawTPDU string
DecodeError string
}
// ReceivedSMSStatus is network delivery evidence for one submitted SMS part.
type ReceivedSMSStatus struct {
DeviceID string
@@ -891,10 +890,8 @@ func (session *Session) parseUSSIReply(response *sipResponse) (string, *int) {
}
func (session *Session) ussiTarget() string {
if number, _, ok := vowifi.ExtractAssociatedMSISDN(session.evidence); ok {
if normalized := normalizeE164(number); normalized != "" {
return "tel:" + normalized
}
if domain := strings.TrimSpace(session.identity.domain); domain != "" {
return "sip:" + domain
}
return session.identity.public
}